github-actions[bot] commited on
Commit
b222bcc
·
1 Parent(s): 57fbb45

🚀 Auto-deploy backend from GitHub (1393543)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .deploy-trigger +0 -1
  2. .env.example +0 -33
  3. .gitattributes +35 -0
  4. analytics.py +23 -18
  5. config/env.sample +11 -16
  6. config/models.yaml +42 -72
  7. datasets/sample_curriculum.json +0 -137
  8. main.py +0 -0
  9. middleware/__init__.py +0 -4
  10. middleware/rate_limiter.py +0 -184
  11. pre_deploy_check.py +2 -10
  12. rag/__init__.py +1 -9
  13. rag/curriculum_rag.py +48 -199
  14. rag/firebase_storage_loader.py +0 -175
  15. rag/pdf_ingestion.py +0 -368
  16. rag/vectorstore_loader.py +2 -11
  17. requirements.txt +0 -8
  18. routes/admin_model_routes.py +0 -67
  19. routes/admin_routes.py +0 -87
  20. routes/curriculum_routes.py +0 -66
  21. routes/diagnostic.py +0 -797
  22. routes/quiz_battle.py +0 -205
  23. routes/quiz_generation_routes.py +0 -356
  24. routes/rag_routes.py +54 -298
  25. routes/video_routes.py +0 -102
  26. scripts/download_vectorstore_from_firebase.py +9 -74
  27. scripts/ingest_curriculum.py +221 -136
  28. scripts/ingest_from_storage.py +0 -285
  29. scripts/migrate_grade12_to_grade11.py +0 -107
  30. scripts/register_firestore_metadata.py +0 -183
  31. scripts/seed_curriculum.py +0 -64
  32. scripts/upload_curriculum_pdfs.py +0 -264
  33. scripts/upload_lesson_modules.py +0 -142
  34. scripts/upload_vectorstore_to_firebase.py +0 -71
  35. services/__init__.py +0 -43
  36. services/ai_client.py +0 -28
  37. services/curriculum_service.py +0 -232
  38. services/inference_client.py +551 -528
  39. services/question_bank_service.py +0 -123
  40. services/user_provisioning_service.py +1 -0
  41. services/variance_engine.py +0 -115
  42. services/youtube_service.py +0 -1017
  43. startup.sh +5 -41
  44. startup_validation.py +21 -94
  45. test_full_rag.py +0 -75
  46. test_retrieval.py +0 -39
  47. tests/README.md +0 -46
  48. tests/test_admin_model_routes.py +0 -213
  49. tests/test_api.py +200 -118
  50. tests/test_hf_monitoring_routes.py +0 -148
.deploy-trigger DELETED
@@ -1 +0,0 @@
1
- 2026-04-29 21:37:27
 
 
.env.example DELETED
@@ -1,33 +0,0 @@
1
- # ── Vector Store ──────────────────────────────────────────────────
2
- # Path to ChromaDB vectorstore directory
3
- CURRICULUM_VECTORSTORE_DIR=datasets/vectorstore
4
-
5
- # Sentence transformer for embeddings
6
- # WARNING: changing this requires full re-ingestion of all curriculum data
7
- EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
8
-
9
- # ── DeepSeek AI Inference ─────────────────────────────────────────
10
- # DeepSeek API key (OpenAI-compatible), required for all AI features
11
- DEEPSEEK_API_KEY=your_deepseek_api_key_here
12
- DEEPSEEK_BASE_URL=https://api.deepseek.com
13
- DEEPSEEK_MODEL=deepseek-chat
14
- DEEPSEEK_REASONER_MODEL=deepseek-reasoner
15
-
16
- # ── HuggingFace (dataset push / HF Space deployment only) ─────────
17
- # HF API token — kept only for HF Space deployment and dataset push
18
- HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
19
-
20
- # HF Model ID for AI monitoring proxy
21
- VITE_HF_MODEL_ID=Qwen/QwQ-32B
22
-
23
- # ── Model Selection ───────────────────────────────────────────────
24
- # LOCAL DEVELOPMENT — deepseek-chat (fast, $0.14/M input)
25
- HF_MODEL_ID=deepseek-chat
26
-
27
- # PRODUCTION — deepseek-reasoner for step-by-step solutions
28
- # HF_MODEL_ID=deepseek-reasoner
29
-
30
- # ── Quiz Battle Internal Auth ─────────────────────────────────────
31
- # Shared secret between Firebase Cloud Functions and FastAPI backend
32
- # Used to authenticate server-to-server requests for correct answers
33
- QUIZ_BATTLE_INTERNAL_SECRET=change_this_to_a_random_string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
analytics.py CHANGED
@@ -232,6 +232,7 @@ class EnhancedRiskRequest(BaseModel):
232
  avgQuizScore: float = Field(..., ge=0, le=100)
233
  attendance: float = Field(..., ge=0, le=100)
234
  assignmentCompletion: float = Field(..., ge=0, le=100)
 
235
  xpGrowthRate: Optional[float] = 0.0
236
  timeOnPlatform: Optional[float] = 0.0 # hours
237
  # Optional trend data
@@ -809,7 +810,7 @@ def _build_risk_features(data: EnhancedRiskRequest) -> np.ndarray:
809
  data.avgQuizScore,
810
  data.attendance,
811
  data.assignmentCompletion,
812
- 0, # streak removed
813
  data.xpGrowthRate or 0.0,
814
  data.timeOnPlatform or 0.0,
815
  data.engagementTrend7d or 0.0,
@@ -870,8 +871,12 @@ def _rule_based_risk(data: EnhancedRiskRequest) -> EnhancedRiskPrediction:
870
  score -= 10
871
  if (data.daysSinceLastActivity or 0) >= 7:
872
  score -= 10
 
 
873
 
874
  # Bonuses
 
 
875
  if (data.engagementTrend7d or 0) > 0:
876
  score += 5
877
 
@@ -1146,19 +1151,19 @@ async def train_risk_model(force_retrain: bool = False) -> RiskTrainResponse:
1146
  if not data:
1147
  continue
1148
 
1149
- features = [
1150
- data.get("engagementScore", 50),
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
1158
- 0.0, # quizScoreVariance
1159
- data.get("consecutiveAbsences", 0),
1160
- data.get("daysSinceLastActivity", 0),
1161
- ]
1162
  X_data.append(features)
1163
 
1164
  # Determine label from existing riskLevel or compute it
@@ -1255,7 +1260,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
1255
  quiz = np.random.normal(35, 12)
1256
  attendance = np.random.normal(50, 15)
1257
  completion = np.random.normal(35, 15)
1258
- # 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,7 +1272,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
1267
  quiz = np.random.normal(60, 10)
1268
  attendance = np.random.normal(72, 10)
1269
  completion = np.random.normal(60, 12)
1270
- # 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,7 +1284,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
1279
  quiz = np.random.normal(85, 8)
1280
  attendance = np.random.normal(93, 5)
1281
  completion = np.random.normal(88, 8)
1282
- # 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,7 +1297,7 @@ def _generate_synthetic_risk_data(n: int) -> Tuple[np.ndarray, np.ndarray]:
1292
  max(0, min(100, quiz)),
1293
  max(0, min(100, attendance)),
1294
  max(0, min(100, completion)),
1295
- 0, # streak removed
1296
  xp_growth,
1297
  max(0, time_platform),
1298
  trend,
 
232
  avgQuizScore: float = Field(..., ge=0, le=100)
233
  attendance: float = Field(..., ge=0, le=100)
234
  assignmentCompletion: float = Field(..., ge=0, le=100)
235
+ streak: Optional[int] = 0
236
  xpGrowthRate: Optional[float] = 0.0
237
  timeOnPlatform: Optional[float] = 0.0 # hours
238
  # Optional trend data
 
810
  data.avgQuizScore,
811
  data.attendance,
812
  data.assignmentCompletion,
813
+ data.streak or 0,
814
  data.xpGrowthRate or 0.0,
815
  data.timeOnPlatform or 0.0,
816
  data.engagementTrend7d or 0.0,
 
871
  score -= 10
872
  if (data.daysSinceLastActivity or 0) >= 7:
873
  score -= 10
874
+ if (data.streak or 0) == 0:
875
+ score -= 5
876
 
877
  # Bonuses
878
+ if (data.streak or 0) >= 7:
879
+ score += 5
880
  if (data.engagementTrend7d or 0) > 0:
881
  score += 5
882
 
 
1151
  if not data:
1152
  continue
1153
 
1154
+ features = [
1155
+ data.get("engagementScore", 50),
1156
+ data.get("avgQuizScore", 50),
1157
+ data.get("attendance", 80),
1158
+ data.get("assignmentCompletion", 60),
1159
+ data.get("streak", 0),
1160
+ data.get("xpGrowthRate", 0),
1161
+ data.get("timeOnPlatform", 0),
1162
+ 0.0, # engagementTrend7d
1163
+ 0.0, # quizScoreVariance
1164
+ data.get("consecutiveAbsences", 0),
1165
+ data.get("daysSinceLastActivity", 0),
1166
+ ]
1167
  X_data.append(features)
1168
 
1169
  # Determine label from existing riskLevel or compute it
 
1260
  quiz = np.random.normal(35, 12)
1261
  attendance = np.random.normal(50, 15)
1262
  completion = np.random.normal(35, 15)
1263
+ streak = max(0, int(np.random.normal(1, 2)))
1264
  xp_growth = np.random.normal(-0.5, 0.3)
1265
  time_platform = np.random.normal(2, 1)
1266
  trend = np.random.normal(-10, 5)
 
1272
  quiz = np.random.normal(60, 10)
1273
  attendance = np.random.normal(72, 10)
1274
  completion = np.random.normal(60, 12)
1275
+ streak = max(0, int(np.random.normal(3, 3)))
1276
  xp_growth = np.random.normal(0.2, 0.3)
1277
  time_platform = np.random.normal(5, 2)
1278
  trend = np.random.normal(0, 8)
 
1284
  quiz = np.random.normal(85, 8)
1285
  attendance = np.random.normal(93, 5)
1286
  completion = np.random.normal(88, 8)
1287
+ streak = max(0, int(np.random.normal(10, 5)))
1288
  xp_growth = np.random.normal(1.0, 0.4)
1289
  time_platform = np.random.normal(10, 3)
1290
  trend = np.random.normal(5, 5)
 
1297
  max(0, min(100, quiz)),
1298
  max(0, min(100, attendance)),
1299
  max(0, min(100, completion)),
1300
+ streak,
1301
  xp_growth,
1302
  max(0, time_platform),
1303
  trend,
config/env.sample CHANGED
@@ -1,16 +1,10 @@
1
- # DeepSeek AI API (OpenAI-compatible)
2
- DEEPSEEK_API_KEY=your_deepseek_api_key_here
3
- DEEPSEEK_BASE_URL=https://api.deepseek.com
4
- DEEPSEEK_MODEL=deepseek-chat
5
- DEEPSEEK_REASONER_MODEL=deepseek-reasoner
6
-
7
  # Inference provider selection
8
  # CI trigger marker: keep this file touchable to force backend deploy workflow runs when needed.
9
- INFERENCE_PROVIDER=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,14 +24,15 @@ INFERENCE_LOCAL_SPACE_URL=http://127.0.0.1:7860
30
  INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
31
  INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
32
 
33
- # 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,13 +64,13 @@ APP_BRAND_AVATAR_URL=
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,7 +90,7 @@ CHAT_STREAM_CONTINUATION_TAIL_CHARS=900
95
  CHAT_STREAM_COMPLETION_MODE_DEFAULT=auto
96
  # Optional: force quiz-generation model. Leave empty to use routing.task_model_map.quiz_generation.
97
  HF_QUIZ_MODEL_ID=
98
- HF_QUIZ_JSON_REPAIR_MODEL_ID=deepseek-chat
99
 
100
  # retry behavior
101
  INFERENCE_MAX_RETRIES=3
 
 
 
 
 
 
 
1
  # Inference provider selection
2
  # CI trigger marker: keep this file touchable to force backend deploy workflow runs when needed.
3
+ INFERENCE_PROVIDER=hf_inference
4
  INFERENCE_PRO_ENABLED=true
5
+ INFERENCE_PRO_PROVIDER=hf_inference
6
+ INFERENCE_GPU_PROVIDER=hf_inference
7
+ INFERENCE_CPU_PROVIDER=hf_inference
8
  INFERENCE_ENABLE_PROVIDER_FALLBACK=true
9
  INFERENCE_PRO_PRIORITY_TASKS=chat,verify_solution
10
  INFERENCE_PRO_ROUTE_HEADER_NAME=
 
24
  INFERENCE_LOCAL_SPACE_GENERATE_PATH=/gradio_api/call/generate
25
  INFERENCE_LOCAL_SPACE_TIMEOUT_SEC=180
26
 
27
+ # hf_inference provider settings
28
  # Alternative env names accepted by runtime/startup checks: HUGGING_FACE_API_TOKEN, HUGGINGFACE_API_TOKEN
29
  HF_TOKEN=your_hf_token
30
  FIREBASE_AUTH_PROJECT_ID=mathpulse-ai-2026
31
  # Prefer one of the options below for backend Firestore/Admin access in deployment:
32
  # FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
33
  # FIREBASE_SERVICE_ACCOUNT_FILE=/path/to/service-account.json
34
+ INFERENCE_HF_BASE_URL=https://router.huggingface.co/hf-inference/models
35
+ INFERENCE_HF_CHAT_URL=https://router.huggingface.co/v1/chat/completions
36
  INFERENCE_HF_TIMEOUT_SEC=90
37
  INFERENCE_INTERACTIVE_TIMEOUT_SEC=55
38
  INFERENCE_BACKGROUND_TIMEOUT_SEC=120
 
64
 
65
  # model defaults
66
  # Global default model for all tasks.
67
+ INFERENCE_MODEL_ID=Qwen/Qwen3-32B
68
  INFERENCE_ENFORCE_QWEN_ONLY=true
69
+ INFERENCE_QWEN_LOCK_MODEL=Qwen/Qwen3-32B
70
  INFERENCE_MAX_NEW_TOKENS=8192
71
  INFERENCE_TEMPERATURE=0.2
72
  INFERENCE_TOP_P=0.9
73
+ INFERENCE_CHAT_MODEL_ID=Qwen/Qwen3-32B
74
  # Temporary chat-only override for experiments (clear to roll back instantly).
75
  # Example: Qwen/Qwen3-32B
76
  INFERENCE_CHAT_MODEL_TEMP_OVERRIDE=
 
90
  CHAT_STREAM_COMPLETION_MODE_DEFAULT=auto
91
  # Optional: force quiz-generation model. Leave empty to use routing.task_model_map.quiz_generation.
92
  HF_QUIZ_MODEL_ID=
93
+ HF_QUIZ_JSON_REPAIR_MODEL_ID=Qwen/Qwen3-32B
94
 
95
  # retry behavior
96
  INFERENCE_MAX_RETRIES=3
config/models.yaml CHANGED
@@ -1,85 +1,55 @@
1
  models:
2
  primary:
3
- id: deepseek-chat
4
- description: Default DeepSeek chat model all chat tasks, quizzes, lessons, reasoning
5
- max_new_tokens: 800
6
- temperature: 0.7
7
  top_p: 0.9
8
 
9
- rag_primary:
10
- id: deepseek-reasoner
11
- description: DeepSeek reasoner extended reasoning for complex RAG tasks
12
- max_new_tokens: 1800
13
- temperature: 0.2
14
- top_p: 0.9
15
- enable_thinking_tasks:
16
- - rag_lesson
17
- - verify_solution
18
- - risk_narrative
19
- no_thinking_tasks:
20
- - chat
21
- - quiz_generation
22
- - learning_path
23
- - daily_insight
24
-
25
- embedding:
26
- id: BAAI/bge-small-en-v1.5
27
- description: Embedding model for RAG retrieval — curriculum vectorstore ingestion and semantic search
28
- note: Not part of the generation pipeline. Read from EMBEDDING_MODEL env var only. Not swappable via admin panel.
29
 
30
- model_capabilities:
31
- sequential_only:
32
- - deepseek-reasoner
33
- supports_thinking:
34
- - deepseek-reasoner
35
 
36
  routing:
37
  task_model_map:
38
- chat: deepseek-chat
39
- verify_solution: deepseek-reasoner
40
- lesson_generation: deepseek-chat
41
- quiz_generation: deepseek-chat
42
- learning_path: deepseek-chat
43
- daily_insight: deepseek-chat
44
- risk_classification: deepseek-chat
45
- risk_narrative: deepseek-reasoner
46
- rag_lesson: deepseek-reasoner
47
- rag_problem: deepseek-chat
48
- rag_analysis_context: deepseek-chat
49
 
50
  task_fallback_model_map:
51
- chat:
52
- - deepseek-chat
53
  verify_solution:
54
- - deepseek-chat
55
- lesson_generation:
56
- - deepseek-chat
57
- quiz_generation:
58
- - deepseek-chat
59
- learning_path:
60
- - deepseek-chat
61
- daily_insight:
62
- - deepseek-chat
63
- risk_classification:
64
- - deepseek-chat
65
- risk_narrative:
66
- - deepseek-chat
67
- rag_lesson:
68
- - deepseek-chat
69
- rag_problem:
70
- - deepseek-chat
71
- rag_analysis_context:
72
- - deepseek-chat
73
 
74
  task_provider_map:
75
- chat: deepseek
76
- verify_solution: deepseek
77
- lesson_generation: deepseek
78
- quiz_generation: deepseek
79
- learning_path: deepseek
80
- daily_insight: deepseek
81
- risk_classification: deepseek
82
- risk_narrative: deepseek
83
- rag_lesson: deepseek
84
- rag_problem: deepseek
85
- rag_analysis_context: deepseek
 
1
  models:
2
  primary:
3
+ id: Qwen/Qwen3-32B
4
+ description: Global default instruction model for interactive Grade 11-12 math tutoring
5
+ max_new_tokens: 640
6
+ temperature: 0.25
7
  top_p: 0.9
8
 
9
+ backup:
10
+ - id: meta-llama/Meta-Llama-3-70B-Instruct
11
+ description: High-quality model used for harder multi-step prompts
12
+ max_new_tokens: 768
13
+ temperature: 0.3
14
+ top_p: 0.9
15
+ - id: google/gemma-2-2b-it
16
+ description: Secondary backup with broad instruction coverage
17
+ max_new_tokens: 384
18
+ temperature: 0.2
19
+ top_p: 0.9
 
 
 
 
 
 
 
 
 
20
 
21
+ experimental:
22
+ - id: mistralai/Mistral-7B-Instruct-v0.3
23
+ notes: Prompt/procedure experimentation
24
+ - id: meta-llama/Meta-Llama-3-8B-Instruct
25
+ notes: Baseline comparison against legacy deployment
26
 
27
  routing:
28
  task_model_map:
29
+ # Keep all task defaults aligned to Qwen3-32B.
30
+ # Hard prompts can still escalate via runtime policy in inference_client.
31
+ chat: Qwen/Qwen3-32B
32
+ verify_solution: Qwen/Qwen3-32B
33
+ lesson_generation: Qwen/Qwen3-32B
34
+ quiz_generation: Qwen/Qwen3-32B
35
+ learning_path: Qwen/Qwen3-32B
36
+ daily_insight: Qwen/Qwen3-32B
37
+ risk_classification: Qwen/Qwen3-32B
38
+ risk_narrative: Qwen/Qwen3-32B
 
39
 
40
  task_fallback_model_map:
41
+ chat: [] # Chat is strict-primary only (no fallback chain)
 
42
  verify_solution:
43
+ - meta-llama/Meta-Llama-3-70B-Instruct # Higher-capacity fallback
44
+ - meta-llama/Llama-3.1-8B-Instruct # Second fallback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  task_provider_map:
47
+ # All tasks use hf_inference router (Qwen/Qwen3-32B natively supported)
48
+ chat: hf_inference
49
+ verify_solution: hf_inference
50
+ lesson_generation: hf_inference
51
+ quiz_generation: hf_inference
52
+ learning_path: hf_inference
53
+ daily_insight: hf_inference
54
+ risk_narrative: hf_inference
55
+ risk_classification: hf_inference
 
 
datasets/sample_curriculum.json DELETED
@@ -1,137 +0,0 @@
1
- [
2
- {
3
- "content": "The learner demonstrates understanding of key concepts of functions. Functions can be represented as ordered pairs, tables of values, graphs, and equations. A function is a relation where each element in the domain corresponds to exactly one element in the range. Key types include linear functions (f(x)=mx+b), quadratic functions (f(x)=ax^2+bx+c), and polynomial functions of higher degrees.",
4
- "subject": "General Mathematics",
5
- "quarter": 1,
6
- "content_domain": "Functions and Their Graphs",
7
- "chunk_type": "content_explanation",
8
- "source_file": "sample_curriculum.json",
9
- "page": 1
10
- },
11
- {
12
- "content": "Learning Competency (M11GM-Ia-1): Represents real-life situations using functions, including piece-wise functions. Example: A taxi fare is computed as P40 for the first 500 meters plus P3.50 for every additional 300 meters or fraction thereof. This is a piecewise function where f(d)=40 for d<=500 and f(d)=40+3.5*ceil((d-500)/300) for d>500.",
13
- "subject": "General Mathematics",
14
- "quarter": 1,
15
- "content_domain": "Functions and Their Graphs",
16
- "chunk_type": "learning_competency",
17
- "source_file": "sample_curriculum.json",
18
- "page": 1
19
- },
20
- {
21
- "content": "Learning Competency (M11GM-Ia-2): Evaluates a function. To evaluate f(x) at x=a, substitute a for every occurrence of x in the expression and simplify. Example: Given f(x)=2x^2-3x+5, evaluate f(2): f(2)=2(4)-3(2)+5=8-6+5=7.",
22
- "subject": "General Mathematics",
23
- "quarter": 1,
24
- "content_domain": "Functions and Their Graphs",
25
- "chunk_type": "content_explanation",
26
- "source_file": "sample_curriculum.json",
27
- "page": 2
28
- },
29
- {
30
- "content": "Rational Functions have the form f(x)=P(x)/Q(x) where P(x) and Q(x) are polynomials and Q(x)!=0. Key features: vertical asymptotes occur where Q(x)=0 but P(x)!=0; horizontal asymptotes depend on the degrees of P and Q. The domain of f(x) excludes all x-values that make the denominator zero. Solving rational equations and inequalities requires careful handling of the denominator signs.",
31
- "subject": "General Mathematics",
32
- "quarter": 1,
33
- "content_domain": "Rational Functions",
34
- "chunk_type": "content_explanation",
35
- "source_file": "sample_curriculum.json",
36
- "page": 3
37
- },
38
- {
39
- "content": "Learning Competency (M11GM-Ib-3): Solves problems involving rational functions, rational equations, and rational inequalities. Example: A jeepney operator's average revenue per trip is modeled by R(n)=(5000+300n)/n where n is the number of trips per day. Find how many trips are needed for average revenue to reach P450.",
40
- "subject": "General Mathematics",
41
- "quarter": 1,
42
- "content_domain": "Rational Functions",
43
- "chunk_type": "learning_competency",
44
- "source_file": "sample_curriculum.json",
45
- "page": 3
46
- },
47
- {
48
- "content": "Exponential Functions f(x)=a*b^x (a!=0, b>0, b!=1) model growth and decay. Key properties: domain is all real numbers; range is (0,infinity) for a>0; horizontal asymptote at y=0; y-intercept at (0,a). Solving exponential equations involves expressing both sides with the same base and equating exponents. Philippine applications include bacterial growth and radioactive decay in medical contexts.",
49
- "subject": "General Mathematics",
50
- "quarter": 2,
51
- "content_domain": "Exponential Functions",
52
- "chunk_type": "content_explanation",
53
- "source_file": "sample_curriculum.json",
54
- "page": 4
55
- },
56
- {
57
- "content": "Compound Interest is calculated using A=P(1+r/n)^(nt) where A is the final amount, P is the principal, r is the annual interest rate (decimal), n is the number of compounding periods per year, and t is the time in years. Philippine banks offer savings and loan products with various compounding frequencies: annually (n=1), semi-annually (n=2), quarterly (n=4), monthly (n=12).",
58
- "subject": "General Mathematics",
59
- "quarter": 3,
60
- "content_domain": "Business Mathematics",
61
- "chunk_type": "content_explanation",
62
- "source_file": "sample_curriculum.json",
63
- "page": 5
64
- },
65
- {
66
- "content": "Learning Competency (M11GM-IIc-1): Illustrates simple and compound interests. Simple interest I=Prt where P is principal, r is rate, t is time. Compound interest uses compounding formula. Example: Juana deposits P50,000 in a bank offering 3.5% interest compounded quarterly. After 3 years, her balance will be A=50000(1+0.035/4)^(4*3)=55543.19 using the compound interest formula.",
67
- "subject": "General Mathematics",
68
- "quarter": 3,
69
- "content_domain": "Business Mathematics",
70
- "chunk_type": "learning_competency",
71
- "source_file": "sample_curriculum.json",
72
- "page": 5
73
- },
74
- {
75
- "content": "Annuities are sequences of equal payments made at equal time intervals. The future value of an ordinary annuity (payment at end of period) is FV=PMT*[(1+r)^n-1]/r and present value is PV=PMT*[1-(1+r)^(-n)]/r. Applications include Pag-IBIG housing loans, SSS contributions, and insurance premiums. Philippine context problems often involve 15-year and 25-year housing loans.",
76
- "subject": "General Mathematics",
77
- "quarter": 3,
78
- "content_domain": "Business Mathematics",
79
- "chunk_type": "content_explanation",
80
- "source_file": "sample_curriculum.json",
81
- "page": 6
82
- },
83
- {
84
- "content": "Stocks and Bonds represent two types of investments. Stocks represent ownership shares in a corporation with dividends as earnings — prices are quoted per share in the Philippine Stock Exchange (PSE). Bonds are debt instruments where the issuing entity borrows money and pays periodic interest then repays principal at maturity. Key computations: stock yield = annual dividend per share / market price; bond yield = annual interest payment / market price.",
85
- "subject": "General Mathematics",
86
- "quarter": 3,
87
- "content_domain": "Business Mathematics",
88
- "chunk_type": "content_explanation",
89
- "source_file": "sample_curriculum.json",
90
- "page": 6
91
- },
92
- {
93
- "content": "A Random Variable is a function that assigns a real number to each outcome in the sample space of a random experiment. A Discrete Random Variable has a countable number of possible values. The probability mass function (PMF) gives the probability P(X=x) for each value x. Key properties: sum of all P(X=x)=1 and P(X=x)>=0 for all x. Common discrete distributions include Binomial for success/failure and Poisson for rare events.",
94
- "subject": "Statistics and Probability",
95
- "quarter": 1,
96
- "content_domain": "Random Variables and Probability Distributions",
97
- "chunk_type": "content_explanation",
98
- "source_file": "sample_curriculum.json",
99
- "page": 7
100
- },
101
- {
102
- "content": "Learning Competency (M11/12SP-IIIa-1): Illustrates a random variable (discrete and continuous). A discrete random variable takes countable values like the number of defective items in a batch of 50 bulbs. A continuous random variable takes infinite uncountable values in an interval, such as the height of Grade 11 students in centimeters. The learner distinguishes between discrete and continuous random variables for real Philippine data.",
103
- "subject": "Statistics and Probability",
104
- "quarter": 1,
105
- "content_domain": "Random Variables and Probability Distributions",
106
- "chunk_type": "learning_competency",
107
- "source_file": "sample_curriculum.json",
108
- "page": 7
109
- },
110
- {
111
- "content": "The Normal Distribution (Gaussian) is a continuous probability distribution with a bell-shaped curve symmetric about the mean mu. Standard normal distribution has mu=0 and sigma=1; converting to standard normal z=(x-mu)/sigma allows probability calculation using z-tables. Properties: 68% of data within 1 sigma of mu, 95% within 2 sigma, 99.7% within 3 sigma. Philippine applications include standardized test scores (NAT, college entrance exams) and quality control in manufacturing.",
112
- "subject": "Statistics and Probability",
113
- "quarter": 1,
114
- "content_domain": "Random Variables and Probability Distributions",
115
- "chunk_type": "content_explanation",
116
- "source_file": "sample_curriculum.json",
117
- "page": 8
118
- },
119
- {
120
- "content": "Conic Sections are curves formed by the intersection of a plane and a double-napped cone. The four types are: Circle (all points equidistant from a center), Parabola (all points equidistant from a focus and directrix), Ellipse (sum of distances to two foci is constant), and Hyperbola (absolute difference of distances to two foci is constant). Standard forms: Circle (x-h)^2+(y-k)^2=r^2; Parabola (x-h)^2=4p(y-k) or (y-k)^2=4p(x-h).",
121
- "subject": "Pre-Calculus",
122
- "quarter": 1,
123
- "content_domain": "Analytic Geometry",
124
- "chunk_type": "content_explanation",
125
- "source_file": "sample_curriculum.json",
126
- "page": 9
127
- },
128
- {
129
- "content": "Learning Competency (STEM_PC11AG-Ia-1): Illustrates the different types of conic sections: circle, parabola, ellipse, and hyperbola. The learner identifies conic sections from their standard equations and determines their key properties including center, radius (for circles), vertex, focus, directrix (for parabolas), and asymptotes (for hyperbolas). Real-world applications include satellite dishes, telescope mirrors, and bridge arch designs.",
130
- "subject": "Pre-Calculus",
131
- "quarter": 1,
132
- "content_domain": "Analytic Geometry",
133
- "chunk_type": "learning_competency",
134
- "source_file": "sample_curriculum.json",
135
- "page": 9
136
- }
137
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
The diff for this file is too large to render. See raw diff
 
middleware/__init__.py DELETED
@@ -1,4 +0,0 @@
1
- # Middleware package
2
- from .rate_limiter import rate_limiter, setup_rate_limiting, RateLimitExceeded
3
-
4
- __all__ = ["rate_limiter", "setup_rate_limiting", "RateLimitExceeded"]
 
 
 
 
 
middleware/rate_limiter.py DELETED
@@ -1,184 +0,0 @@
1
- """
2
- Rate limiting middleware using slowapi.
3
- """
4
- import os
5
- import logging
6
-
7
- from fastapi import Request
8
- from slowapi import Limiter
9
- from slowapi.errors import RateLimitExceeded as SlowAPIRateLimitExceeded
10
-
11
- logger = logging.getLogger("mathpulse.ratelimit")
12
-
13
- # Environment-based configuration with defaults
14
- RATE_LIMIT_AI_RPM = int(os.getenv("RATE_LIMIT_AI_RPM", "20"))
15
- RATE_LIMIT_QUIZ_GENERATE_RPM = int(os.getenv("RATE_LIMIT_QUIZ_GENERATE_RPM", "10"))
16
- RATE_LIMIT_QUIZ_SUBMIT_RPM = int(os.getenv("RATE_LIMIT_QUIZ_SUBMIT_RPM", "30"))
17
- RATE_LIMIT_AUTH_RPM = int(os.getenv("RATE_LIMIT_AUTH_RPM", "5"))
18
- RATE_LIMIT_LEADERBOARD_RPM = int(os.getenv("RATE_LIMIT_LEADERBOARD_RPM", "60"))
19
- RATE_LIMIT_DEFAULT_RPM = int(os.getenv("RATE_LIMIT_DEFAULT_RPM", "100"))
20
- RATE_LIMIT_ADMIN_MULTIPLIER = int(os.getenv("RATE_LIMIT_ADMIN_MULTIPLIER", "10"))
21
- RATE_LIMIT_TEACHER_MULTIPLIER = int(os.getenv("RATE_LIMIT_TEACHER_MULTIPLIER", "3"))
22
-
23
- # Role multipliers for rate limit adjustment
24
- ROLE_MULTIPLIERS = {
25
- "admin": RATE_LIMIT_ADMIN_MULTIPLIER,
26
- "teacher": RATE_LIMIT_TEACHER_MULTIPLIER,
27
- "student": 1,
28
- }
29
-
30
-
31
- def _get_user_identifier(request: Request) -> str:
32
- """
33
- Extract user identifier for rate limiting.
34
- Uses Firebase UID from request.state.user if authenticated, otherwise falls back to IP.
35
- """
36
- user = getattr(request.state, "user", None)
37
- if user and hasattr(user, "uid") and user.uid:
38
- return f"uid:{user.uid}"
39
-
40
- if request.client:
41
- return f"ip:{request.client.host}"
42
- return "ip:unknown"
43
-
44
-
45
- def _get_user_role(request: Request) -> str:
46
- """Get user role from request state for multiplier calculation."""
47
- user = getattr(request.state, "user", None)
48
- if user and hasattr(user, "role") and user.role:
49
- return user.role
50
- return "student"
51
-
52
-
53
- def _get_role_multiplier(request: Request) -> int:
54
- """Get rate limit multiplier based on user role."""
55
- role = _get_user_role(request)
56
- return ROLE_MULTIPLIERS.get(role, 1)
57
-
58
-
59
- class MathPulseLimiter:
60
- """
61
- Rate limiter with role-aware multipliers for MathPulse AI.
62
- """
63
-
64
- def __init__(self) -> None:
65
- self._limiter = Limiter(
66
- key_func=_get_user_identifier,
67
- storage_uri="memory://",
68
- default_limits=[f"{RATE_LIMIT_DEFAULT_RPM}/minute"],
69
- )
70
-
71
- @property
72
- def limiter(self) -> Limiter:
73
- return self._limiter
74
-
75
- def _get_adjusted_limit(self, base_rpm: int, request: Request) -> int:
76
- """Apply role multiplier to base rate limit."""
77
- multiplier = _get_role_multiplier(request)
78
- return base_rpm * multiplier
79
-
80
- def ai_limit(self, request: Request) -> str:
81
- """Rate limit for AI endpoints with role adjustment."""
82
- limit = self._get_adjusted_limit(RATE_LIMIT_AI_RPM, request)
83
- return f"{limit}/minute"
84
-
85
- def quiz_generate_limit(self, request: Request) -> str:
86
- """Rate limit for quiz generation with role adjustment."""
87
- limit = self._get_adjusted_limit(RATE_LIMIT_QUIZ_GENERATE_RPM, request)
88
- return f"{limit}/minute"
89
-
90
- def quiz_submit_limit(self, request: Request) -> str:
91
- """Rate limit for quiz submission with role adjustment."""
92
- limit = self._get_adjusted_limit(RATE_LIMIT_QUIZ_SUBMIT_RPM, request)
93
- return f"{limit}/minute"
94
-
95
- def auth_limit(self, request: Request) -> str:
96
- """Rate limit for auth endpoints with role adjustment."""
97
- limit = self._get_adjusted_limit(RATE_LIMIT_AUTH_RPM, request)
98
- return f"{limit}/minute"
99
-
100
- def leaderboard_limit(self, request: Request) -> str:
101
- """Rate limit for leaderboard with role adjustment."""
102
- limit = self._get_adjusted_limit(RATE_LIMIT_LEADERBOARD_RPM, request)
103
- return f"{limit}/minute"
104
-
105
- def default_limit(self, request: Request) -> str:
106
- """Default rate limit with role adjustment."""
107
- limit = self._get_adjusted_limit(RATE_LIMIT_DEFAULT_RPM, request)
108
- return f"{limit}/minute"
109
-
110
-
111
- # Global rate limiter instance
112
- rate_limiter = MathPulseLimiter()
113
-
114
-
115
- def setup_rate_limiting(app) -> None:
116
- """
117
- Set up rate limiting for the FastAPI application.
118
- """
119
-
120
- # Add limiter to app state
121
- app.state.limiter = rate_limiter.limiter
122
-
123
- # Add slowapi exception handler
124
- app.add_exception_handler(
125
- SlowAPIRateLimitExceeded,
126
- lambda request, exc: _rate_limit_exceeded_handler(request, exc)
127
- )
128
-
129
- logger.info(
130
- f"Rate limiting configured: AI={RATE_LIMIT_AI_RPM}/min, "
131
- f"QuizGen={RATE_LIMIT_QUIZ_GENERATE_RPM}/min, "
132
- f"Auth={RATE_LIMIT_AUTH_RPM}/min, "
133
- f"Admin={RATE_LIMIT_ADMIN_MULTIPLIER}x, Teacher={RATE_LIMIT_TEACHER_MULTIPLIER}x"
134
- )
135
-
136
-
137
- def _rate_limit_exceeded_handler(request: Request, exc: SlowAPIRateLimitExceeded):
138
- """Handle rate limit exceeded errors with proper JSON response."""
139
- from fastapi.responses import JSONResponse
140
-
141
- retry_after = getattr(exc, "retry_after", 60)
142
- return JSONResponse(
143
- status_code=429,
144
- content={
145
- "error": "rate_limit_exceeded",
146
- "message": "Too many requests. Please try again later.",
147
- "retry_after": retry_after,
148
- },
149
- headers={
150
- "Retry-After": str(retry_after),
151
- "Content-Type": "application/json",
152
- }
153
- )
154
-
155
-
156
- # Decorator helpers
157
- def ai_rate_limit():
158
- """Decorator for AI endpoint rate limiting."""
159
- return rate_limiter.limiter.limit(rate_limiter.ai_limit)
160
-
161
-
162
- def quiz_generate_rate_limit():
163
- """Decorator for quiz generation rate limiting."""
164
- return rate_limiter.limiter.limit(rate_limiter.quiz_generate_limit)
165
-
166
-
167
- def quiz_submit_rate_limit():
168
- """Decorator for quiz submit rate limiting."""
169
- return rate_limiter.limiter.limit(rate_limiter.quiz_submit_limit)
170
-
171
-
172
- def auth_rate_limit():
173
- """Decorator for auth endpoint rate limiting."""
174
- return rate_limiter.limiter.limit(rate_limiter.auth_limit)
175
-
176
-
177
- def leaderboard_rate_limit():
178
- """Decorator for leaderboard rate limiting."""
179
- return rate_limiter.limiter.limit(rate_limiter.leaderboard_limit)
180
-
181
-
182
- def default_rate_limit():
183
- """Decorator for default rate limiting."""
184
- return rate_limiter.limiter.limit(rate_limiter.default_limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pre_deploy_check.py CHANGED
@@ -16,16 +16,8 @@ Exit codes:
16
  import sys
17
  import os
18
 
19
- # Add 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."""
 
16
  import sys
17
  import os
18
 
19
+ # Add backend to path
20
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
 
 
 
 
 
 
 
 
21
 
22
  def main() -> int:
23
  """Run pre-deployment checks."""
rag/__init__.py CHANGED
@@ -5,19 +5,11 @@ from .curriculum_rag import (
5
  build_lesson_prompt,
6
  build_problem_generation_prompt,
7
  build_analysis_curriculum_context,
8
- build_lesson_query,
9
- format_retrieved_chunks,
10
- summarize_retrieval_confidence,
11
  )
12
- from .vectorstore_loader import reset_vectorstore_singleton
13
 
14
  __all__ = [
15
  "retrieve_curriculum_context",
16
  "build_lesson_prompt",
17
  "build_problem_generation_prompt",
18
  "build_analysis_curriculum_context",
19
- "build_lesson_query",
20
- "format_retrieved_chunks",
21
- "summarize_retrieval_confidence",
22
- "reset_vectorstore_singleton",
23
- ]
 
5
  build_lesson_prompt,
6
  build_problem_generation_prompt,
7
  build_analysis_curriculum_context,
 
 
 
8
  )
 
9
 
10
  __all__ = [
11
  "retrieve_curriculum_context",
12
  "build_lesson_prompt",
13
  "build_problem_generation_prompt",
14
  "build_analysis_curriculum_context",
15
+ ]
 
 
 
 
rag/curriculum_rag.py CHANGED
@@ -1,10 +1,8 @@
1
- """
2
- Updated curriculum RAG with exact match retrieval and 7-section notebook output.
3
- """
4
-
5
  from __future__ import annotations
6
 
7
- from typing import Dict, List, Optional, Tuple
 
 
8
 
9
 
10
  def _to_where(
@@ -12,10 +10,6 @@ def _to_where(
12
  quarter: Optional[int] = None,
13
  content_domain: Optional[str] = None,
14
  chunk_type: Optional[str] = None,
15
- module_id: Optional[str] = None,
16
- lesson_id: Optional[str] = None,
17
- competency_code: Optional[str] = None,
18
- storage_path: Optional[str] = None,
19
  ) -> Optional[Dict[str, object]]:
20
  clauses = []
21
  if subject:
@@ -26,14 +20,6 @@ def _to_where(
26
  clauses.append({"content_domain": {"$eq": content_domain}})
27
  if chunk_type:
28
  clauses.append({"chunk_type": {"$eq": chunk_type}})
29
- if module_id:
30
- clauses.append({"module_id": {"$eq": module_id}})
31
- if lesson_id:
32
- clauses.append({"lesson_id": {"$eq": lesson_id}})
33
- if competency_code:
34
- clauses.append({"competency_code": {"$eq": competency_code}})
35
- if storage_path:
36
- clauses.append({"storage_path": {"$eq": storage_path}})
37
  if not clauses:
38
  return None
39
  if len(clauses) == 1:
@@ -42,6 +28,7 @@ def _to_where(
42
 
43
 
44
  def _distance_to_score(distance: float) -> float:
 
45
  return round(1.0 / (1.0 + max(distance, 0.0)), 4)
46
 
47
 
@@ -51,23 +38,12 @@ def retrieve_curriculum_context(
51
  quarter: int | None = None,
52
  content_domain: str | None = None,
53
  chunk_type: str | None = None,
54
- 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,39 +59,20 @@ def retrieve_curriculum_context(
83
  for idx, content in enumerate(documents):
84
  md = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
85
  distance = float(distances[idx]) if idx < len(distances) else 1.0
86
- rows.append({
87
- "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(
@@ -136,120 +93,30 @@ def build_lesson_query(
136
  return " | ".join(parts)
137
 
138
 
139
- def retrieve_lesson_pdf_context(
140
- topic: str,
141
- subject: str,
142
- quarter: int,
143
- lesson_title: str | None = None,
144
- competency: str | None = None,
145
- module_id: str | None = None,
146
- lesson_id: str | None = None,
147
- competency_code: str | None = None,
148
- storage_path: str | None = None,
149
- top_k: int = 8,
150
- ) -> Tuple[list[dict], str]:
151
- """Retrieve chunks by storage_path exact match + semantic ranking; fallback to general query.
152
-
153
- NOTE: Curriculum PDF chunks are often tagged with quarter=1 even when they cover all quarters.
154
- We first try the exact quarter, then fallback to quarter=1, then no quarter filter.
155
- """
156
- # Try 1: Exact match with storage_path + quarter
157
- if storage_path:
158
- exact_chunks = retrieve_curriculum_context(
159
- query=topic,
160
- subject=subject,
161
- quarter=quarter,
162
- storage_path=storage_path,
163
- top_k=top_k,
164
- )
165
- if exact_chunks and any(c["score"] >= 0.65 for c in exact_chunks):
166
- return exact_chunks, "exact"
167
-
168
- # Try 2: General query with exact quarter
169
- general_chunks = retrieve_curriculum_context(
170
- query=topic,
171
- subject=subject,
172
- quarter=quarter,
173
- top_k=top_k,
174
- )
175
-
176
- # Try 3: Fallback to quarter=1 (most curriculum PDFs are tagged Q1)
177
- if not general_chunks and quarter != 1:
178
- general_chunks = retrieve_curriculum_context(
179
- query=topic,
180
- subject=subject,
181
- quarter=1,
182
- top_k=top_k,
183
- )
184
-
185
- # Try 4: Final fallback - no quarter filter at all
186
- if not general_chunks:
187
- general_chunks = retrieve_curriculum_context(
188
- query=topic,
189
- subject=subject,
190
- top_k=top_k,
191
- )
192
-
193
- if storage_path and exact_chunks:
194
- all_chunks = exact_chunks + general_chunks
195
- seen = set()
196
- deduped = []
197
- for c in all_chunks:
198
- key = f"{c.get('source_file')}:{c.get('page')}:{c.get('content', '')[:60]}"
199
- if key not in seen:
200
- seen.add(key)
201
- deduped.append(c)
202
- deduped.sort(key=lambda x: x.get("score", 0), reverse=True)
203
- return deduped[:top_k], "hybrid"
204
-
205
- return general_chunks, "general"
206
-
207
-
208
  def format_retrieved_chunks(curriculum_chunks: list[dict]) -> str:
209
- 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,57 +129,39 @@ def build_lesson_prompt(
262
  learner_level: Optional[str],
263
  module_unit: Optional[str],
264
  curriculum_chunks: list[dict],
265
- competency_code: Optional[str] = None,
266
  ) -> str:
267
  refs_text = format_retrieved_chunks(curriculum_chunks)
268
- organized = organize_chunks_by_section(curriculum_chunks)
269
-
270
  return (
271
- "You are a 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,7 +184,7 @@ def build_analysis_curriculum_context(weak_topics: list[str], subject: str) -> l
335
  top_k=2,
336
  )
337
  for row in rows:
338
- key = f"{row.get('source_file')}::{row.get('page')}::{row.get('content', '')[:80]}"
339
  if key not in dedup:
340
  dedup[key] = row
341
- return list(dedup.values())
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from typing import Dict, List, Optional
4
+
5
+ from .vectorstore_loader import get_vectorstore_components
6
 
7
 
8
  def _to_where(
 
10
  quarter: Optional[int] = None,
11
  content_domain: Optional[str] = None,
12
  chunk_type: Optional[str] = None,
 
 
 
 
13
  ) -> Optional[Dict[str, object]]:
14
  clauses = []
15
  if subject:
 
20
  clauses.append({"content_domain": {"$eq": content_domain}})
21
  if chunk_type:
22
  clauses.append({"chunk_type": {"$eq": chunk_type}})
 
 
 
 
 
 
 
 
23
  if not clauses:
24
  return None
25
  if len(clauses) == 1:
 
28
 
29
 
30
  def _distance_to_score(distance: float) -> float:
31
+ # Chroma returns smaller distance for better matches. Map to (0,1].
32
  return round(1.0 / (1.0 + max(distance, 0.0)), 4)
33
 
34
 
 
38
  quarter: int | None = None,
39
  content_domain: str | None = None,
40
  chunk_type: str | None = None,
41
+ top_k: int = 5,
 
 
 
 
42
  ) -> list[dict]:
 
 
43
  _, collection, embedder = get_vectorstore_components()
44
+ where = _to_where(subject, quarter, content_domain, chunk_type)
 
 
 
 
 
 
45
 
46
+ query_embedding = embedder.encode(query).tolist()
47
  result = collection.query(
48
  query_embeddings=[query_embedding],
49
  n_results=max(1, top_k),
 
59
  for idx, content in enumerate(documents):
60
  md = metadatas[idx] if idx < len(metadatas) and isinstance(metadatas[idx], dict) else {}
61
  distance = float(distances[idx]) if idx < len(distances) else 1.0
62
+ rows.append(
63
+ {
64
+ "content": str(content or ""),
65
+ "subject": str(md.get("subject") or "unknown"),
66
+ "quarter": int(md.get("quarter") or 0),
67
+ "content_domain": str(md.get("content_domain") or "unknown"),
68
+ "chunk_type": str(md.get("chunk_type") or "unknown"),
69
+ "source_file": str(md.get("source_file") or ""),
70
+ "page": int(md.get("page") or 0),
71
+ "score": _distance_to_score(distance),
72
+ }
73
+ )
 
 
 
 
74
 
75
+ return rows
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
 
78
  def build_lesson_query(
 
93
  return " | ".join(parts)
94
 
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  def format_retrieved_chunks(curriculum_chunks: list[dict]) -> str:
97
+ references = []
98
  for i, chunk in enumerate(curriculum_chunks, start=1):
99
+ references.append(
100
  f"{i}. [{chunk.get('source_file')} p.{chunk.get('page')}] "
101
  f"({chunk.get('content_domain')}/{chunk.get('chunk_type')}) score={chunk.get('score')}\n"
102
  f" Excerpt: {chunk.get('content', '')}"
103
  )
104
+ return "\n".join(references) if references else "No curriculum context retrieved."
105
 
106
 
107
+ def summarize_retrieval_confidence(curriculum_chunks: list[dict]) -> Dict[str, float | str]:
108
  if not curriculum_chunks:
109
+ return {"confidence": 0.0, "band": "low"}
110
 
111
+ top_scores = [float(chunk.get("score") or 0.0) for chunk in curriculum_chunks[:5]]
112
  score = sum(top_scores) / max(1, len(top_scores))
113
+ if score >= 0.72:
114
+ band = "high"
115
+ elif score >= 0.5:
116
+ band = "medium"
117
+ else:
118
+ band = "low"
119
+ return {"confidence": round(score, 3), "band": band}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
 
122
  def build_lesson_prompt(
 
129
  learner_level: Optional[str],
130
  module_unit: Optional[str],
131
  curriculum_chunks: list[dict],
 
132
  ) -> str:
133
  refs_text = format_retrieved_chunks(curriculum_chunks)
 
 
134
  return (
135
+ "You are a Grade 11-12 DepEd SHS math instructional designer.\n"
136
+ "Generate JSON only. Use ONLY the retrieved curriculum evidence below. Do not invent competencies or content beyond the retrieved scope.\n\n"
 
137
  f"Lesson title: {lesson_title}\n"
 
138
  f"Curriculum competency: {competency}\n"
139
  f"Grade level: {grade_level}\n"
140
  f"Subject: {subject}\n"
141
  f"Quarter: Q{quarter}\n"
142
+ f"Learner level: {learner_level or 'mixed'}\n"
143
  f"Module/unit: {module_unit or 'n/a'}\n\n"
144
  "[CURRICULUM CONTEXT]\n"
145
  f"{refs_text}\n\n"
146
+ "Return JSON with these keys only:\n"
147
+ "lessonTitle, curriculumCompetency, lessonObjective, realWorldHook, explanation, workedExample, guidedPractice, independentPractice, quickAssessment, reflectionPrompt, sourceCitations, needsReview, reviewReason\n\n"
 
 
 
 
 
 
 
 
 
 
 
148
  "Rules:\n"
149
+ "- Keep the lesson age-appropriate for SHS learners.\n"
150
+ "- Use real Philippine contexts where possible, such as payroll, VAT, discounts, loans, logistics, travel, or school data.\n"
151
+ "- If evidence is thin, set needsReview=true and explain why in reviewReason.\n"
152
+ "- Do not mention unsupported curriculum facts.\n"
153
+ "- sourceCitations should be an array of short citations referencing the retrieved excerpts."
 
 
154
  )
155
 
156
 
157
  def build_problem_generation_prompt(topic: str, difficulty: str, curriculum_chunks: list[dict]) -> str:
158
+ references = []
159
  for i, chunk in enumerate(curriculum_chunks, start=1):
160
+ references.append(
161
  f"{i}. [{chunk.get('source_file')} p.{chunk.get('page')}] "
162
  f"({chunk.get('content_domain')}/{chunk.get('chunk_type')}) {chunk.get('content', '')}"
163
  )
164
+ refs_text = "\n".join(references) if references else "No curriculum context retrieved."
165
 
166
  return (
167
  "Generate one practice problem strictly aligned to the retrieved DepEd competency scope.\n"
 
184
  top_k=2,
185
  )
186
  for row in rows:
187
+ key = f"{row.get('source_file')}::{row.get('page')}::{row.get('content')[:80]}"
188
  if key not in dedup:
189
  dedup[key] = row
190
+ return list(dedup.values())
rag/firebase_storage_loader.py DELETED
@@ -1,175 +0,0 @@
1
- """
2
- Firebase Storage PDF loader for curriculum ingestion.
3
- Downloads PDFs from Firebase Storage and extracts text for ChromaDB indexing.
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import logging
9
- import os
10
- from pathlib import Path
11
- from typing import Dict, List, Optional, Tuple
12
-
13
- logger = logging.getLogger("mathpulse.fb_storage_loader")
14
-
15
- _FIREBASE_INITIALIZED = False
16
-
17
-
18
- def _init_firebase_storage() -> Tuple[any, any]:
19
- global _FIREBASE_INITIALIZED
20
-
21
- if _FIREBASE_INITIALIZED:
22
- try:
23
- from firebase_admin import storage as fb_storage
24
- bucket = fb_storage.bucket()
25
- return fb_storage, bucket
26
- except Exception as e:
27
- logger.warning("Firebase storage unavailable: %s", e)
28
- _FIREBASE_INITIALIZED = False
29
- return None, None
30
-
31
- try:
32
- import firebase_admin
33
- from firebase_admin import credentials, storage
34
- except ImportError:
35
- logger.warning("firebase_admin not installed")
36
- return None, None
37
-
38
- if firebase_admin._apps:
39
- _FIREBASE_INITIALIZED = True
40
- try:
41
- bucket = storage.bucket()
42
- return storage, bucket
43
- except Exception as e:
44
- logger.warning("Firebase storage bucket unavailable: %s", e)
45
- return None, None
46
-
47
- sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
48
- sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
49
- bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
50
-
51
- try:
52
- if sa_json:
53
- import json as _json
54
- creds = credentials.Certificate(_json.loads(sa_json))
55
- elif sa_file and Path(sa_file).exists():
56
- creds = credentials.Certificate(sa_file)
57
- else:
58
- creds = credentials.ApplicationDefault()
59
-
60
- firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
61
- _FIREBASE_INITIALIZED = True
62
- bucket = storage.bucket()
63
- return storage, bucket
64
- except Exception as e:
65
- logger.warning("Firebase init failed: %s", e)
66
- return None, None
67
-
68
-
69
- def download_pdf_from_storage(storage_path: str, dest_path: Optional[str] = None) -> Optional[bytes]:
70
- """Download a PDF from Firebase Storage and return its bytes."""
71
- _, bucket = _init_firebase_storage()
72
- if bucket is None:
73
- logger.warning("Firebase Storage not available, skipping download")
74
- return None
75
-
76
- try:
77
- blob = bucket.blob(storage_path)
78
- if not blob.exists():
79
- logger.warning("Blob does not exist: %s", storage_path)
80
- return None
81
- bytes_data = blob.download_as_bytes()
82
- logger.info("Downloaded %s (%d bytes)", storage_path, len(bytes_data))
83
-
84
- if dest_path:
85
- Path(dest_path).parent.mkdir(parents=True, exist_ok=True)
86
- with open(dest_path, "wb") as f:
87
- f.write(bytes_data)
88
- logger.info("Saved to %s", dest_path)
89
-
90
- return bytes_data
91
- except Exception as e:
92
- logger.error("Failed to download %s: %s", storage_path, e)
93
- return None
94
-
95
-
96
- def list_curriculum_blobs(prefix: str = "curriculum/") -> List[Dict[str, str]]:
97
- """List all blobs under a prefix in Firebase Storage."""
98
- _, bucket = _init_firebase_storage()
99
- if bucket is None:
100
- return []
101
-
102
- blobs = bucket.list_blobs(prefix=prefix)
103
- result = []
104
- for blob in blobs:
105
- if blob.name.endswith(".pdf"):
106
- result.append({
107
- "name": blob.name,
108
- "size": blob.size,
109
- "updated": str(blob.updated) if blob.updated else None,
110
- "download_url": f"https://storage.googleapis.com/{bucket.name}/{blob.name}",
111
- })
112
- return result
113
-
114
-
115
- # NOTE: Curriculum guide PDFs (shaping papers) are stored in Firebase Storage
116
- # for system reference but are NOT included in RAG ingestion because they
117
- # contain only learning objectives and course descriptions — insufficient
118
- # content for lesson generation (typically <10 chunks each).
119
- #
120
- # Only SDO teaching modules (full lesson content with examples and problems)
121
- # are included in the RAG pipeline.
122
-
123
- PDF_METADATA: Dict[str, dict] = {
124
- # General Mathematics Q1 — SDO Navotas teaching module (100 pages, ~117k chars)
125
- "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": {
126
- "subject": "General Mathematics",
127
- "subjectId": "gen-math",
128
- "type": "sdo_module",
129
- "content_domain": "general",
130
- "quarter": 1,
131
- "storage_path": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
132
- },
133
- # General Mathematics Q2 — Interest & Annuities modules (~27-35 pages each)
134
- "curriculum/general_math/genmath_q2_mod1_simpleandcompoundinterests_v2.pdf": {
135
- "subject": "General Mathematics",
136
- "subjectId": "gen-math",
137
- "type": "sdo_module",
138
- "content_domain": "general",
139
- "quarter": 2,
140
- "storage_path": "curriculum/general_math/genmath_q2_mod1_simpleandcompoundinterests_v2.pdf",
141
- },
142
- "curriculum/general_math/genmath_q2_mod2_interestmaturityfutureandpresentvaluesinsimpleandcompoundinterests_v2.pdf": {
143
- "subject": "General Mathematics",
144
- "subjectId": "gen-math",
145
- "type": "sdo_module",
146
- "content_domain": "general",
147
- "quarter": 2,
148
- "storage_path": "curriculum/general_math/genmath_q2_mod2_interestmaturityfutureandpresentvaluesinsimpleandcompoundinterests_v2.pdf",
149
- },
150
- "curriculum/general_math/genmath_q2_mod3_SolvingProblemsInvolvingSimpleandCompoundInterest_v2.pdf": {
151
- "subject": "General Mathematics",
152
- "subjectId": "gen-math",
153
- "type": "sdo_module",
154
- "content_domain": "general",
155
- "quarter": 2,
156
- "storage_path": "curriculum/general_math/genmath_q2_mod3_SolvingProblemsInvolvingSimpleandCompoundInterest_v2.pdf",
157
- },
158
- "curriculum/general_math/genmath_q2_mod4_simpleandgeneralannuities_v2.pdf": {
159
- "subject": "General Mathematics",
160
- "subjectId": "gen-math",
161
- "type": "sdo_module",
162
- "content_domain": "general",
163
- "quarter": 2,
164
- "storage_path": "curriculum/general_math/genmath_q2_mod4_simpleandgeneralannuities_v2.pdf",
165
- },
166
- # Statistics and Probability — Full textbook (331 pages, ~607k chars)
167
- "curriculum/stat_prob/Full.pdf": {
168
- "subject": "Statistics and Probability",
169
- "subjectId": "stats-prob",
170
- "type": "sdo_module",
171
- "content_domain": "statistics",
172
- "quarter": 1,
173
- "storage_path": "curriculum/stat_prob/Full.pdf",
174
- },
175
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rag/pdf_ingestion.py DELETED
@@ -1,368 +0,0 @@
1
- """
2
- PDF Ingestion Module for Quiz Battle RAG Question Bank.
3
-
4
- Ingests PDFs from Firebase Storage, extracts text, chunks content,
5
- generates embeddings, calls DeepSeek to produce base questions,
6
- and stores results in Firestore.
7
- """
8
-
9
- import asyncio
10
- import hashlib
11
- import io
12
- import json
13
- import logging
14
- import os
15
- import random
16
- from dataclasses import dataclass
17
- from datetime import datetime, timezone
18
- from typing import Optional
19
-
20
- from google.cloud.firestore import Client
21
- from langchain_text_splitters import RecursiveCharacterTextSplitter
22
- from sentence_transformers import SentenceTransformer
23
- import pypdf
24
-
25
- from rag.firebase_storage_loader import _init_firebase_storage
26
- from services.ai_client import get_deepseek_client, CHAT_MODEL
27
-
28
- logger = logging.getLogger(__name__)
29
-
30
- EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2")
31
- DEFAULT_FIREBASE_PROJECT = os.getenv("FIREBASE_AUTH_PROJECT_ID", "mathpulse-ai-2026")
32
-
33
-
34
- @dataclass
35
- class IngestionResult:
36
- """Result of a PDF ingestion operation."""
37
-
38
- filename: str
39
- processed: bool
40
- question_count: int
41
- grade_level: int
42
- topic: str
43
- storage_path: str
44
- timestamp: datetime
45
-
46
-
47
- def _extract_filename(storage_path: str) -> str:
48
- """Extract filename from a Firebase Storage path."""
49
- return storage_path.split("/")[-1]
50
-
51
-
52
- def _generate_chunk_id(source_chunk_id: str, question_text: str) -> str:
53
- """Generate a unique document ID for a question."""
54
- return hashlib.md5(f"{source_chunk_id}:{question_text}".encode()).hexdigest()
55
-
56
-
57
- def _strip_json_fences(text: str) -> str:
58
- """Strip markdown JSON fences from text."""
59
- text = text.strip()
60
- if text.startswith("```json"):
61
- text = text[7:]
62
- if text.startswith("```"):
63
- text = text[3:]
64
- if text.endswith("```"):
65
- text = text[:-3]
66
- return text.strip()
67
-
68
-
69
- async def _generate_questions_for_chunk(
70
- chunk_text: str,
71
- chunk_id: str,
72
- topic: str,
73
- grade_level: int,
74
- deepseek_client,
75
- ) -> list[dict]:
76
- """Call DeepSeek to generate MCQs for a text chunk."""
77
- system_prompt = (
78
- "You are a DepEd-aligned math question generator for Filipino students. "
79
- "Given a curriculum excerpt, generate 5 multiple-choice questions. "
80
- "Return ONLY a JSON array. No markdown, no explanation."
81
- )
82
-
83
- user_prompt = f"""Given this curriculum excerpt:
84
- <chunk>
85
- {chunk_text}
86
- </chunk>
87
-
88
- Generate 5 multiple-choice questions. For each question output JSON:
89
- {{
90
- "question": "...",
91
- "choices": ["A) ...", "B) ...", "C) ...", "D) ..."],
92
- "correct_answer": "A",
93
- "explanation": "...",
94
- "topic": "{topic}",
95
- "difficulty": "easy|medium|hard",
96
- "grade_level": {grade_level},
97
- "source_chunk_id": "{chunk_id}"
98
- }}
99
- Return a JSON array only, no extra text."""
100
-
101
- try:
102
- response = deepseek_client.chat.completions.create(
103
- model=CHAT_MODEL,
104
- messages=[
105
- {"role": "system", "content": system_prompt},
106
- {"role": "user", "content": user_prompt},
107
- ],
108
- temperature=0.7,
109
- )
110
- raw_response = response.choices[0].message.content
111
- clean_response = _strip_json_fences(raw_response)
112
- questions = json.loads(clean_response)
113
- return questions if isinstance(questions, list) else []
114
- except json.JSONDecodeError as e:
115
- logger.error(f"Failed to parse DeepSeek response as JSON for chunk {chunk_id}: {e}")
116
- return []
117
- except Exception as e:
118
- logger.error(f"Error calling DeepSeek for chunk {chunk_id}: {e}")
119
- return []
120
-
121
-
122
- def _chunk_text(text: str) -> list[str]:
123
- """Split text into chunks using RecursiveCharacterTextSplitter."""
124
- splitter = RecursiveCharacterTextSplitter(
125
- chunk_size=500,
126
- chunk_overlap=50,
127
- length_function=len,
128
- separators=["\n\n", "\n", " ", ""],
129
- )
130
- return splitter.split_text(text)
131
-
132
-
133
- def _extract_pdf_text(pdf_bytes: bytes) -> str:
134
- """Extract text from PDF bytes using pypdf."""
135
- reader = pypdf.PdfReader(io.BytesIO(pdf_bytes))
136
- text_parts = []
137
- for page in reader.pages:
138
- text_parts.append(page.extract_text())
139
- return "\n".join(text_parts)
140
-
141
-
142
- async def _save_questions_batch(
143
- firestore_client: Client,
144
- questions: list[dict],
145
- grade_level: int,
146
- topic: str,
147
- ) -> int:
148
- """Save questions to Firestore using batch writes. Returns count saved."""
149
- batch = firestore_client.batch()
150
- question_count = 0
151
-
152
- for question in questions:
153
- doc_id = question.get("id") or _generate_chunk_id(
154
- question.get("source_chunk_id", ""),
155
- question.get("question", ""),
156
- )
157
- doc_ref = firestore_client.collection("question_bank").document(
158
- str(grade_level)
159
- ).collection(topic).document("questions").collection("questions").document(doc_id)
160
-
161
- doc_data = {
162
- "question": question.get("question", ""),
163
- "choices": question.get("choices", []),
164
- "correct_answer": question.get("correct_answer", ""),
165
- "explanation": question.get("explanation", ""),
166
- "topic": question.get("topic", topic),
167
- "difficulty": question.get("difficulty", "medium"),
168
- "grade_level": question.get("grade_level", grade_level),
169
- "source_chunk_id": question.get("source_chunk_id", ""),
170
- "random_seed": random.random(),
171
- "created_at": datetime.now(timezone.utc),
172
- }
173
- batch.set(doc_ref, doc_data)
174
- question_count += 1
175
-
176
- if question_count % 500 == 0:
177
- await batch.commit()
178
- batch = firestore_client.batch()
179
-
180
- await batch.commit()
181
- return question_count
182
-
183
-
184
- async def _save_embeddings_batch(
185
- firestore_client: Client,
186
- chunks: list[dict],
187
- filename: str,
188
- ) -> int:
189
- """Save chunk embeddings to Firestore. Returns count saved."""
190
- batch = firestore_client.batch()
191
- count = 0
192
-
193
- for chunk in chunks:
194
- chunk_id = chunk["id"]
195
- doc_ref = firestore_client.collection("question_bank_embeddings").document(chunk_id)
196
- doc_data = {
197
- "chunk_id": chunk_id,
198
- "text": chunk["text"],
199
- "embedding": chunk["embedding"],
200
- "filename": filename,
201
- "created_at": datetime.now(timezone.utc),
202
- }
203
- batch.set(doc_ref, doc_data)
204
- count += 1
205
-
206
- if count % 500 == 0:
207
- await batch.commit()
208
- batch = firestore_client.batch()
209
-
210
- await batch.commit()
211
- return count
212
-
213
-
214
- async def _save_processing_manifest(
215
- firestore_client: Client,
216
- filename: str,
217
- question_count: int,
218
- chunk_count: int,
219
- grade_level: int,
220
- topic: str,
221
- storage_path: str,
222
- ) -> None:
223
- """Save processing manifest to Firestore."""
224
- doc_ref = firestore_client.collection("pdf_processing_status").document(filename)
225
- doc_data = {
226
- "filename": filename,
227
- "question_count": question_count,
228
- "chunk_count": chunk_count,
229
- "grade_level": grade_level,
230
- "topic": topic,
231
- "storage_path": storage_path,
232
- "processed_at": datetime.now(timezone.utc),
233
- "status": "completed",
234
- }
235
- await doc_ref.set(doc_data)
236
-
237
-
238
- async def ingest_pdf(
239
- storage_path: str,
240
- grade_level: int,
241
- topic: str,
242
- force_reingest: bool = False,
243
- ) -> IngestionResult:
244
- """
245
- Ingest a PDF from Firebase Storage, generate questions, and store in Firestore.
246
-
247
- Args:
248
- storage_path: Path to PDF in Firebase Storage (e.g., "rag-pdfs/filename.pdf")
249
- grade_level: Grade level (11 or 12)
250
- topic: Topic identifier for the questions
251
- force_reingest: If True, reprocess even if already processed
252
-
253
- Returns:
254
- IngestionResult with processing summary
255
- """
256
- filename = _extract_filename(storage_path)
257
- project_id = os.getenv("FIREBASE_AUTH_PROJECT_ID", DEFAULT_FIREBASE_PROJECT)
258
- firestore_client = Client(project=project_id)
259
-
260
- # Step 1: Check if already processed
261
- if not force_reingest:
262
- status_ref = firestore_client.collection("pdf_processing_status").document(filename)
263
- status_doc = await status_ref.get()
264
- if status_doc.exists:
265
- logger.info(f"PDF {filename} already processed, skipping (use force_reingest=True to override)")
266
- data = status_doc.to_dict() or {}
267
- return IngestionResult(
268
- filename=filename,
269
- processed=True,
270
- question_count=data.get("question_count", 0),
271
- grade_level=data.get("grade_level", grade_level),
272
- topic=data.get("topic", topic),
273
- storage_path=data.get("storage_path", storage_path),
274
- timestamp=data.get("timestamp", datetime.now(timezone.utc)),
275
- )
276
-
277
- # Step 2: Download PDF from Firebase Storage
278
- try:
279
- _, bucket = _init_firebase_storage()
280
- blob = bucket.blob(storage_path)
281
- pdf_bytes = blob.download_as_bytes()
282
- except Exception as e:
283
- logger.error(f"Failed to download PDF from Firebase Storage: {e}")
284
- return IngestionResult(
285
- filename=filename,
286
- processed=False,
287
- question_count=0,
288
- grade_level=grade_level,
289
- topic=topic,
290
- storage_path=storage_path,
291
- timestamp=datetime.now(timezone.utc),
292
- )
293
-
294
- # Step 3: Extract text from PDF
295
- try:
296
- text = _extract_pdf_text(pdf_bytes)
297
- except Exception as e:
298
- logger.error(f"Failed to extract text from PDF: {e}")
299
- return IngestionResult(
300
- filename=filename,
301
- processed=False,
302
- question_count=0,
303
- grade_level=grade_level,
304
- topic=topic,
305
- storage_path=storage_path,
306
- timestamp=datetime.now(timezone.utc),
307
- )
308
-
309
- # Step 4: Chunk text
310
- chunks = _chunk_text(text)
311
-
312
- # Step 5: Generate embeddings
313
- embedding_model = SentenceTransformer(EMBEDDING_MODEL)
314
- chunk_ids = []
315
- chunk_data = []
316
-
317
- for i, chunk_text in enumerate(chunks):
318
- chunk_id = hashlib.md5(f"{filename}:{i}:{chunk_text[:100]}".encode()).hexdigest()
319
- embedding = embedding_model.encode(chunk_text).tolist()
320
- chunk_ids.append(chunk_id)
321
- chunk_data.append({
322
- "id": chunk_id,
323
- "text": chunk_text,
324
- "embedding": embedding,
325
- })
326
-
327
- # Step 6: Initialize DeepSeek client
328
- deepseek_client = get_deepseek_client()
329
-
330
- # Step 7: Generate questions for each chunk
331
- all_questions = []
332
- for i, chunk_text in enumerate(chunks):
333
- chunk_id = chunk_ids[i]
334
- questions = await _generate_questions_for_chunk(
335
- chunk_text, chunk_id, topic, grade_level, deepseek_client
336
- )
337
- for q in questions:
338
- q["id"] = _generate_chunk_id(chunk_id, q.get("question", ""))
339
- all_questions.extend(questions)
340
-
341
- # Step 8: Save questions to Firestore
342
- question_count = await _save_questions_batch(
343
- firestore_client, all_questions, grade_level, topic
344
- )
345
-
346
- # Step 9: Save embeddings to Firestore
347
- await _save_embeddings_batch(firestore_client, chunk_data, filename)
348
-
349
- # Step 10: Save manifest to Firestore
350
- await _save_processing_manifest(
351
- firestore_client, filename, question_count, len(chunks),
352
- grade_level, topic, storage_path
353
- )
354
-
355
- logger.info(
356
- f"Completed ingestion for {filename}: {question_count} questions, "
357
- f"{len(chunks)} chunks"
358
- )
359
-
360
- return IngestionResult(
361
- filename=filename,
362
- processed=True,
363
- question_count=question_count,
364
- grade_level=grade_level,
365
- topic=topic,
366
- storage_path=storage_path,
367
- timestamp=datetime.now(timezone.utc),
368
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rag/vectorstore_loader.py CHANGED
@@ -12,12 +12,6 @@ _VECTORSTORE_LOCK = Lock()
12
  _VECTORSTORE_SINGLETON: Tuple[Any, Any, SentenceTransformer] | None = None
13
 
14
 
15
- def reset_vectorstore_singleton() -> None:
16
- global _VECTORSTORE_SINGLETON
17
- with _VECTORSTORE_LOCK:
18
- _VECTORSTORE_SINGLETON = None
19
-
20
-
21
  def _resolve_vectorstore_dir() -> Path:
22
  raw = os.getenv("CURRICULUM_VECTORSTORE_DIR", "datasets/vectorstore")
23
  path = Path(raw)
@@ -34,7 +28,7 @@ def _resolve_vectorstore_dir() -> Path:
34
 
35
  def get_vectorstore_components(
36
  collection_name: str = "curriculum_chunks",
37
- model_name: str = "BAAI/bge-base-en-v1.5",
38
  ):
39
  global _VECTORSTORE_SINGLETON
40
  if _VECTORSTORE_SINGLETON is None:
@@ -43,10 +37,7 @@ def get_vectorstore_components(
43
  vectorstore_dir = _resolve_vectorstore_dir()
44
  vectorstore_dir.mkdir(parents=True, exist_ok=True)
45
  client = chromadb.PersistentClient(path=str(vectorstore_dir))
46
- collection = client.get_or_create_collection(
47
- name=collection_name,
48
- metadata={"hnsw:space": "cosine"},
49
- )
50
  embedder = SentenceTransformer(model_name)
51
  _VECTORSTORE_SINGLETON = (client, collection, embedder)
52
  return _VECTORSTORE_SINGLETON
 
12
  _VECTORSTORE_SINGLETON: Tuple[Any, Any, SentenceTransformer] | None = None
13
 
14
 
 
 
 
 
 
 
15
  def _resolve_vectorstore_dir() -> Path:
16
  raw = os.getenv("CURRICULUM_VECTORSTORE_DIR", "datasets/vectorstore")
17
  path = Path(raw)
 
28
 
29
  def get_vectorstore_components(
30
  collection_name: str = "curriculum_chunks",
31
+ model_name: str = "BAAI/bge-small-en-v1.5",
32
  ):
33
  global _VECTORSTORE_SINGLETON
34
  if _VECTORSTORE_SINGLETON is None:
 
37
  vectorstore_dir = _resolve_vectorstore_dir()
38
  vectorstore_dir.mkdir(parents=True, exist_ok=True)
39
  client = chromadb.PersistentClient(path=str(vectorstore_dir))
40
+ collection = client.get_or_create_collection(name=collection_name)
 
 
 
41
  embedder = SentenceTransformer(model_name)
42
  _VECTORSTORE_SINGLETON = (client, collection, embedder)
43
  return _VECTORSTORE_SINGLETON
requirements.txt CHANGED
@@ -1,6 +1,5 @@
1
  fastapi>=0.104.0
2
  uvicorn[standard]>=0.24.0
3
- openai>=1.0.0
4
  huggingface-hub>=0.31.0
5
  requests>=2.31.0
6
  pandas==2.2.3
@@ -20,10 +19,3 @@ numpy==2.2.1
20
  firebase-admin>=6.2.0
21
  redis[hiredis]>=5.0.0
22
  PyYAML>=6.0.0
23
- mypy>=1.20.0
24
- pytest>=9.0.0
25
- pytest-asyncio>=0.23.0
26
- google-api-python-client>=2.0.0
27
- pypdf>=4.0.0
28
- slowapi>=0.1.0
29
- limits>=3.0.0
 
1
  fastapi>=0.104.0
2
  uvicorn[standard]>=0.24.0
 
3
  huggingface-hub>=0.31.0
4
  requests>=2.31.0
5
  pandas==2.2.3
 
19
  firebase-admin>=6.2.0
20
  redis[hiredis]>=5.0.0
21
  PyYAML>=6.0.0
 
 
 
 
 
 
 
routes/admin_model_routes.py DELETED
@@ -1,67 +0,0 @@
1
- from fastapi import APIRouter, Depends, HTTPException, Request
2
- from pydantic import BaseModel
3
- from services.inference_client import (
4
- set_runtime_model_profile, set_runtime_model_override,
5
- reset_runtime_overrides, get_current_runtime_config, _MODEL_PROFILES,
6
- )
7
-
8
- router = APIRouter(prefix="/api/admin/model-config", tags=["admin"])
9
-
10
- ALLOWED_OVERRIDE_KEYS = {
11
- "INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
12
- "HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
13
- }
14
-
15
-
16
- def require_admin(request: Request):
17
- user = getattr(request.state, "user", None)
18
- if user is None:
19
- raise HTTPException(status_code=401, detail="Authentication required")
20
- if user.role != "admin":
21
- raise HTTPException(status_code=403, detail="Admin access required")
22
- return user
23
-
24
-
25
- class ProfileSwitchRequest(BaseModel):
26
- profile: str
27
-
28
-
29
- class OverrideRequest(BaseModel):
30
- key: str
31
- value: str
32
-
33
-
34
- @router.get("")
35
- def get_model_config(_admin=Depends(require_admin)):
36
- return {
37
- **get_current_runtime_config(),
38
- "availableProfiles": list(_MODEL_PROFILES.keys()),
39
- "profileDescriptions": {
40
- "dev": "deepseek-chat everywhere - fast, $0.14/M input",
41
- "budget": "deepseek-chat for all tasks - minimal cost",
42
- "prod": "deepseek-reasoner for RAG, deepseek-chat for chat - best quality",
43
- },
44
- }
45
-
46
-
47
- @router.post("/profile")
48
- def switch_profile(req: ProfileSwitchRequest, _admin=Depends(require_admin)):
49
- try:
50
- set_runtime_model_profile(req.profile)
51
- return {"success": True, "applied": get_current_runtime_config()}
52
- except ValueError as e:
53
- raise HTTPException(status_code=400, detail=str(e))
54
-
55
-
56
- @router.post("/override")
57
- def set_override(req: OverrideRequest, _admin=Depends(require_admin)):
58
- if req.key not in ALLOWED_OVERRIDE_KEYS:
59
- raise HTTPException(status_code=400, detail=f"Key '{req.key}' is not overridable.")
60
- set_runtime_model_override(req.key, req.value)
61
- return {"success": True, "applied": get_current_runtime_config()}
62
-
63
-
64
- @router.delete("/reset")
65
- def reset_to_env(_admin=Depends(require_admin)):
66
- reset_runtime_overrides()
67
- return {"success": True, "current": get_current_runtime_config()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routes/admin_routes.py DELETED
@@ -1,87 +0,0 @@
1
- from typing import Optional
2
- from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, BackgroundTasks
3
- from pydantic import BaseModel
4
- import logging
5
-
6
- from rag.firebase_storage_loader import _init_firebase_storage, PDF_METADATA
7
- from scripts.ingest_from_storage import ingest_from_firebase_storage
8
-
9
- logger = logging.getLogger("mathpulse.admin")
10
-
11
- router = APIRouter(prefix="/api/admin", tags=["admin"])
12
-
13
- def require_admin(request: Request):
14
- user = getattr(request.state, "user", None)
15
- if user is None:
16
- raise HTTPException(status_code=401, detail="Authentication required")
17
- if user.role != "admin":
18
- raise HTTPException(status_code=403, detail="Admin access required")
19
- return user
20
-
21
- class ReingestRequest(BaseModel):
22
- subjectId: Optional[str] = None
23
- storagePath: Optional[str] = None
24
-
25
- @router.post("/upload-pdf")
26
- async def upload_pdf(
27
- subjectId: str = Form(...),
28
- subjectName: str = Form(...),
29
- semester: int = Form(...),
30
- quarter: int = Form(...),
31
- file: UploadFile = File(...),
32
- _admin=Depends(require_admin)
33
- ):
34
- if not file.filename.endswith('.pdf'):
35
- raise HTTPException(status_code=400, detail="Only PDF files are allowed.")
36
-
37
- file_content = await file.read()
38
- if len(file_content) > 50 * 1024 * 1024:
39
- raise HTTPException(status_code=400, detail="File size exceeds 50MB limit.")
40
-
41
- _, bucket = _init_firebase_storage()
42
- if not bucket:
43
- raise HTTPException(status_code=500, detail="Firebase storage is not initialized.")
44
-
45
- storage_path = f"curriculum/{subjectId}/{file.filename}"
46
-
47
- try:
48
- blob = bucket.blob(storage_path)
49
- blob.upload_from_string(file_content, content_type="application/pdf")
50
- except Exception as e:
51
- logger.error(f"Failed to upload PDF: {e}")
52
- raise HTTPException(status_code=500, detail=f"Failed to upload to Firebase Storage: {e}")
53
-
54
- # Update metadata in memory before reingesting
55
- PDF_METADATA[storage_path] = {
56
- "subject": subjectName,
57
- "subjectId": subjectId,
58
- "type": "uploaded_module",
59
- "semester": semester,
60
- "quarter": quarter
61
- }
62
-
63
- # Reingest
64
- try:
65
- ingest_from_firebase_storage(force_reindex=True)
66
- except Exception as e:
67
- logger.error(f"Failed to trigger reingestion: {e}")
68
-
69
- storage_url = f"gs://{bucket.name}/{storage_path}"
70
- return {
71
- "success": True,
72
- "chunkCount": 0,
73
- "subjectId": subjectId,
74
- "storageUrl": storage_url
75
- }
76
-
77
- @router.post("/reingest-pdf")
78
- async def reingest_pdf(
79
- req: Optional[ReingestRequest] = None,
80
- _admin=Depends(require_admin)
81
- ):
82
- try:
83
- ingest_from_firebase_storage(force_reindex=True)
84
- return {"success": True, "message": "Reingestion triggered successfully."}
85
- except Exception as e:
86
- logger.error(f"Failed to reingest: {e}")
87
- raise HTTPException(status_code=500, detail=f"Failed to reingest: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routes/curriculum_routes.py DELETED
@@ -1,66 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- from typing import Optional
5
-
6
- from fastapi import APIRouter, HTTPException, Query
7
- from pydantic import BaseModel
8
-
9
- from services.curriculum_service import (
10
- get_subject,
11
- get_subjects,
12
- get_topic,
13
- get_topics,
14
- )
15
-
16
- logger = logging.getLogger("mathpulse.curriculum")
17
- router = APIRouter(prefix="/api/curriculum", tags=["curriculum"])
18
-
19
-
20
- class SubjectResponse(BaseModel):
21
- id: str
22
- code: str
23
- name: str
24
- gradeLevel: str
25
- semester: str
26
- color: str
27
- pdfAvailable: bool
28
- topics: list
29
-
30
-
31
- class TopicResponse(BaseModel):
32
- id: str
33
- name: str
34
- unit: str
35
-
36
-
37
- @router.get("/subjects", response_model=list[SubjectResponse])
38
- async def list_subjects(grade_level: Optional[str] = Query(None, description="Filter by grade level (e.g., 'Grade 11', 'Grade 12')")):
39
- """List all curriculum subjects, optionally filtered by grade level."""
40
- subjects = get_subjects(grade_level)
41
- return subjects
42
-
43
-
44
- @router.get("/subjects/{subject_id}", response_model=SubjectResponse)
45
- async def get_subject_by_id(subject_id: str):
46
- """Get a specific subject by ID."""
47
- subject = get_subject(subject_id)
48
- if not subject:
49
- raise HTTPException(status_code=404, detail=f"Subject not found: {subject_id}")
50
- return subject
51
-
52
-
53
- @router.get("/subjects/{subject_id}/topics", response_model=list[TopicResponse])
54
- async def list_subject_topics(subject_id: str):
55
- """List all topics for a subject."""
56
- topics = get_topics(subject_id)
57
- return topics
58
-
59
-
60
- @router.get("/subjects/{subject_id}/topics/{topic_id}", response_model=TopicResponse)
61
- async def get_topic_by_id(subject_id: str, topic_id: str):
62
- """Get a specific topic."""
63
- topic = get_topic(subject_id, topic_id)
64
- if not topic:
65
- raise HTTPException(status_code=404, detail=f"Topic not found: {subject_id}/{topic_id}")
66
- return topic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routes/diagnostic.py DELETED
@@ -1,797 +0,0 @@
1
- """
2
- MathPulse AI - Diagnostic Assessment Router
3
- POST /api/diagnostic/generate - Generate 15-item diagnostic test grounded in RAG curriculum
4
- POST /api/diagnostic/submit - Score responses, run risk analysis, save to Firestore
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import json
11
- import logging
12
- import time
13
- import traceback
14
- import uuid
15
- from collections import defaultdict
16
- from datetime import datetime, timezone
17
- from typing import Any, Dict, List, Optional
18
-
19
- from fastapi import APIRouter, HTTPException, Request
20
- from pydantic import BaseModel, Field
21
-
22
- from services.ai_client import CHAT_MODEL, get_deepseek_client
23
- from rag.curriculum_rag import retrieve_curriculum_context
24
- import firebase_admin
25
- from firebase_admin import firestore as fs
26
-
27
- logger = logging.getLogger("mathpulse.diagnostic")
28
-
29
- router = APIRouter(prefix="/api/diagnostic", tags=["diagnostic"])
30
-
31
- # In-memory fallback session store (used if Firestore is unavailable)
32
- # This ensures assessment works even without Firebase credentials
33
- _in_memory_sessions: Dict[str, Dict[str, Any]] = defaultdict(dict)
34
-
35
-
36
- # ─── Pydantic Models ───────────────────────────────────────────────
37
-
38
- class DiagnosticGenerateRequest(BaseModel):
39
- strand: str = Field(..., description="Student strand: ABM, STEM, HUMSS, GAS, TVL")
40
- grade_level: str = Field(..., description="Grade level: Grade 11 or Grade 12")
41
-
42
-
43
- class DiagnosticOption(BaseModel):
44
- A: str
45
- B: str
46
- C: str
47
- D: str
48
-
49
-
50
- class DiagnosticQuestionStripped(BaseModel):
51
- question_id: str
52
- competency_code: str
53
- domain: str
54
- topic: str
55
- difficulty: str
56
- bloom_level: str
57
- question_text: str
58
- options: DiagnosticOption
59
- curriculum_reference: str
60
-
61
-
62
- class DiagnosticGenerateResponse(BaseModel):
63
- test_id: str
64
- questions: List[DiagnosticQuestionStripped]
65
- total_items: int
66
- estimated_minutes: float
67
-
68
-
69
- class DiagnosticResponseItem(BaseModel):
70
- question_id: str
71
- student_answer: str
72
- time_spent_seconds: int
73
-
74
-
75
- class DiagnosticSubmitRequest(BaseModel):
76
- test_id: str
77
- responses: List[DiagnosticResponseItem]
78
-
79
-
80
- class MasterySummary(BaseModel):
81
- mastered: List[str]
82
- developing: List[str]
83
- beginning: List[str]
84
-
85
-
86
- class DiagnosticSubmitResponse(BaseModel):
87
- success: bool
88
- overall_risk: str
89
- overall_score_percent: float
90
- mastery_summary: MasterySummary
91
- recommended_intervention: str
92
- xp_earned: int
93
- badge_unlocked: str
94
- redirect_to: str
95
-
96
-
97
- # ─── Competency Code Registry ───────────────────────────────────────
98
-
99
- COMPETENCY_REGISTRY = {
100
- "NA-WAGE-01": {"subject": "General Mathematics", "title": "Wages, Salaries, Overtime, Commissions, VAT"},
101
- "NA-SEQ-01": {"subject": "General Mathematics", "title": "Arithmetic Sequences and Series"},
102
- "NA-SEQ-02": {"subject": "General Mathematics", "title": "Geometric Sequences and Series"},
103
- "NA-FUNC-01": {"subject": "General Mathematics", "title": "Functions, Relations, Vertical Line Test"},
104
- "NA-FUNC-02": {"subject": "General Mathematics", "title": "Evaluating Functions, Operations, Composition"},
105
- "NA-FUNC-03": {"subject": "General Mathematics", "title": "One-to-One Functions, Inverse Functions"},
106
- "NA-EXP-01": {"subject": "General Mathematics", "title": "Exponential Functions, Equations, Inequalities"},
107
- "NA-LOG-01": {"subject": "General Mathematics", "title": "Logarithmic Functions"},
108
- "MG-TRIG-01": {"subject": "General Mathematics", "title": "Trigonometric Ratios, Right Triangles"},
109
- "NA-FIN-01": {"subject": "General Mathematics", "title": "Compound Interest, Maturity Value"},
110
- "NA-FIN-02": {"subject": "General Mathematics", "title": "Simple and General Annuities"},
111
- "NA-FIN-04": {"subject": "General Mathematics", "title": "Business and Consumer Loans, Amortization"},
112
- "NA-LOGIC-01": {"subject": "General Mathematics", "title": "Logical Propositions, Connectives, Truth Tables"},
113
- "BM-FDP-01": {"subject": "Business Mathematics", "title": "Fractions, Decimals, Percent Conversions"},
114
- "BM-FDP-02": {"subject": "Business Mathematics", "title": "Proportion: Direct, Inverse, Partitive"},
115
- "BM-BUS-01": {"subject": "Business Mathematics", "title": "Markup, Margin, Trade Discounts, VAT"},
116
- "BM-BUS-02": {"subject": "Business Mathematics", "title": "Profit, Loss, Break-even Point"},
117
- "BM-COMM-01": {"subject": "Business Mathematics", "title": "Straight Commission, Salary Plus Commission"},
118
- "BM-COMM-02": {"subject": "Business Mathematics", "title": "Commission on Cash and Installment Basis"},
119
- "BM-SW-01": {"subject": "Business Mathematics", "title": "Salary vs. Wage, Income"},
120
- "BM-SW-03": {"subject": "Business Mathematics", "title": "Mandatory Deductions: SSS, PhilHealth, Pag-IBIG"},
121
- "BM-SW-04": {"subject": "Business Mathematics", "title": "Overtime Pay Computation (Labor Code)"},
122
- "SP-RV-01": {"subject": "Statistics & Probability", "title": "Random Variables, Discrete vs. Continuous"},
123
- "SP-RV-02": {"subject": "Statistics & Probability", "title": "Probability Distribution, Mean, Variance, SD"},
124
- "SP-NORM-01": {"subject": "Statistics & Probability", "title": "Normal Curve Properties"},
125
- "SP-NORM-02": {"subject": "Statistics & Probability", "title": "Z-Scores, Standard Normal Table"},
126
- "SP-SAMP-01": {"subject": "Statistics & Probability", "title": "Types of Random Sampling"},
127
- "SP-SAMP-03": {"subject": "Statistics & Probability", "title": "Central Limit Theorem"},
128
- "SP-HYP-01": {"subject": "Statistics & Probability", "title": "Hypothesis Testing: H0 and Ha"},
129
- "FM1-MAT-01": {"subject": "Finite Mathematics", "title": "Matrices and Matrix Operations"},
130
- "FM2-PROB-01": {"subject": "Finite Mathematics", "title": "Counting Principles and Permutations"},
131
- "FM2-PROB-02": {"subject": "Finite Mathematics", "title": "Combinations and Probability"},
132
- }
133
-
134
- LEARNING_PATH_ORDER: Dict[str, List[str]] = {
135
- "BM": ["BM-FDP-01", "BM-FDP-02", "BM-BUS-01", "BM-BUS-02", "BM-COMM-01",
136
- "BM-COMM-02", "BM-SW-01", "BM-SW-03", "BM-SW-04"],
137
- "NA": ["NA-WAGE-01", "NA-SEQ-01", "NA-SEQ-02", "NA-FUNC-01", "NA-FUNC-02",
138
- "NA-FUNC-03", "NA-EXP-01", "NA-LOG-01", "NA-FIN-01", "NA-FIN-02",
139
- "NA-FIN-04", "NA-LOGIC-01"],
140
- "SP": ["SP-RV-01", "SP-RV-02", "SP-NORM-01", "SP-NORM-02", "SP-SAMP-01",
141
- "SP-SAMP-03", "SP-HYP-01"],
142
- }
143
-
144
-
145
- STRAND_SUBJECTS: Dict[str, List[str]] = {
146
- "ABM": ["General Mathematics", "Business Mathematics"],
147
- "STEM": ["General Mathematics", "Statistics and Probability"],
148
- "HUMSS": ["General Mathematics"],
149
- "GAS": ["General Mathematics"],
150
- "TVL": ["General Mathematics"],
151
- }
152
-
153
-
154
- FULL_QUESTION_SCHEMA: Dict[str, List[str]] = {
155
- "ABM": [
156
- "General Mathematics: 5 items",
157
- "Business Mathematics: 5 items",
158
- "Statistics & Probability: 5 items",
159
- ],
160
- "STEM": [
161
- "General Mathematics: 7 items",
162
- "Statistics & Probability: 5 items",
163
- "Finite Mathematics: 3 items",
164
- ],
165
- "HUMSS": ["General Mathematics: 15 items"],
166
- "GAS": ["General Mathematics: 15 items"],
167
- "TVL": ["General Mathematics: 15 items"],
168
- }
169
-
170
- STRAND_COVERAGE_TEXT: Dict[str, str] = {
171
- "ABM": """FOR ABM STRAND:
172
- - 5 questions: General Mathematics (NA-WAGE, NA-SEQ, NA-FIN topics -- wages, sequences, interest)
173
- - 5 questions: Business Mathematics (BM-FDP, BM-BUS, BM-COMM, BM-SW topics -- percent, markup, commission, salaries, deductions using SSS/PhilHealth/Pag-IBIG rates)
174
- - 5 questions: Statistics & Probability (SP-RV, SP-NORM topics -- random variables, normal distribution, z-scores)""",
175
- "STEM": """FOR STEM STRAND:
176
- - 7 questions: General Mathematics (NA-FUNC, NA-EXP, NA-LOG, MG-TRIG, NA-FIN -- functions, exponentials, trigonometry, financial math)
177
- - 5 questions: Statistics & Probability (SP-RV, SP-NORM, SP-SAMP, SP-HYP -- distributions, sampling, hypothesis)
178
- - 3 questions: Finite Mathematics (FM1-MAT or FM2-PROB -- matrices or counting/probability)""",
179
- "HUMSS": """FOR HUMSS STRAND:
180
- - 15 questions: General Mathematics only (spread across NA-WAGE, NA-SEQ, NA-FUNC, NA-FIN, NA-LOGIC -- wages, sequences, functions, interest, logic)""",
181
- "GAS": """FOR GAS STRAND:
182
- - 15 questions: General Mathematics only (spread across NA-WAGE, NA-SEQ, NA-FUNC, NA-FIN, NA-LOGIC -- wages, sequences, functions, interest, logic)""",
183
- "TVL": """FOR TVL STRAND:
184
- - 15 questions: General Mathematics only (spread across NA-WAGE, NA-SEQ, NA-FUNC, NA-FIN, NA-LOGIC -- wages, sequences, functions, interest, logic)""",
185
- }
186
-
187
-
188
- def _get_strand_coverage(strand: str) -> str:
189
- return STRAND_COVERAGE_TEXT.get(strand.upper(), STRAND_COVERAGE_TEXT["STEM"])
190
-
191
-
192
- def _build_rag_context(strand: str) -> str:
193
- subjects = STRAND_SUBJECTS.get(strand.upper(), ["General Mathematics"])
194
- rag_context_parts: List[str] = []
195
-
196
- rag_query = f"SHS {strand} diagnostic assessment competency questions Grade 11"
197
-
198
- for subject in subjects:
199
- try:
200
- chunks = retrieve_curriculum_context(
201
- query=rag_query,
202
- subject=subject,
203
- top_k=3,
204
- )
205
- except Exception as e:
206
- logger.warning(f"[WARN] RAG unavailable for {subject}: {e}")
207
- continue
208
-
209
- if not chunks:
210
- continue
211
-
212
- chunk_texts: List[str] = []
213
- for chunk in chunks:
214
- source = chunk.get("source_file", "unknown")
215
- content = str(chunk.get("content", ""))[:600]
216
- chunk_texts.append(f"[Source: {source}]\n{content}")
217
- rag_context_parts.append(
218
- f"\n=== {subject.upper()} CURRICULUM REFERENCE ===\n" + "\n---\n".join(chunk_texts)
219
- )
220
-
221
- if not rag_context_parts:
222
- logger.warning("[WARN] RAG unavailable for diagnostic generation -- proceeding without curriculum context")
223
- return ""
224
-
225
- return "\n".join(rag_context_parts)
226
-
227
-
228
- async def _get_previous_questions(
229
- user_id: str,
230
- firestore_client,
231
- max_attempts: int = 3,
232
- ) -> list[str]:
233
- """Fetch question texts from the user's last N assessment attempts to avoid duplicates."""
234
- try:
235
- attempts_ref = (
236
- firestore_client.collection("assessmentResults")
237
- .document(user_id)
238
- .collection("attempts")
239
- .order_by("completedAt", direction=fs.Query.DESCENDING)
240
- .limit(max_attempts)
241
- )
242
- docs = attempts_ref.stream()
243
- previous_texts: list[str] = []
244
- for doc in docs:
245
- data = doc.to_dict()
246
- answers = data.get("answers", [])
247
- for a in answers:
248
- previous_texts.append(a.get("questionText", ""))
249
- return previous_texts
250
- except Exception:
251
- return []
252
-
253
-
254
- def _build_system_prompt(strand: str, grade_level: str, rag_context: str, variance_seed: int = 0, previous_questions: list[str] | None = None) -> str:
255
- strand_upper = strand.upper()
256
- coverage_text = _get_strand_coverage(strand_upper)
257
-
258
- rag_block = ""
259
- if rag_context:
260
- rag_block = f"""
261
- OFFICIAL CURRICULUM REFERENCE (from indexed DepEd modules via RAG):
262
- {rag_context}
263
-
264
- IMPORTANT: Base ALL questions strictly on the curriculum content above.
265
- Do not invent formulas, definitions, or problem types not found in the
266
- reference material. If the reference material is insufficient for a topic,
267
- use only standard DepEd SHS competencies for that strand.
268
- """
269
-
270
- previous_block = ""
271
- if previous_questions:
272
- previous_lines = [
273
- "PREVIOUS QUESTIONS TO AVOID (DO NOT REPEAT):",
274
- "The following questions were already asked to this student.",
275
- "You MUST NOT reuse or rephrase any of these:",
276
- ]
277
- for i, q in enumerate(previous_questions[:20], 1):
278
- previous_lines.append(f"{i}. {q}")
279
- previous_block = "\n".join(previous_lines) + "\n\n"
280
-
281
- variance_block = ""
282
- if variance_seed > 0:
283
- variance_block = (
284
- f"VARIANCE SEED: {variance_seed}\n"
285
- "To ensure unique questions, use this seed to generate DIFFERENT "
286
- "numerical values, problem contexts, and variable names compared "
287
- "to the standard template.\n\n"
288
- )
289
-
290
- return f"""SYSTEM ROLE:
291
- You are MathPulse AI's Diagnostic Test Generator. Your job is to create a
292
- 15-item multiple-choice diagnostic assessment for a Filipino SHS student,
293
- strictly grounded in the DepEd Strengthened SHS Curriculum (SDO Navotas
294
- modules and DepEd K-12 Curriculum Guides).
295
-
296
- STUDENT CONTEXT:
297
- - Strand: {strand_upper}
298
- - Grade Level: {grade_level}
299
- - Test Purpose: DIAGNOSTIC (pre-learning, not summative -- assess current
300
- knowledge to build a personalized learning path)
301
- {rag_block}
302
- STRAND-SUBJECT COVERAGE:
303
- Generate 15 questions distributed across these subjects and domains:
304
-
305
- {coverage_text}
306
-
307
- COMPETENCY CODE FORMAT:
308
- Assign each question exactly one competency_code from this registry:
309
- General Math: NA-WAGE-01, NA-SEQ-01, NA-SEQ-02, NA-FUNC-01,
310
- NA-FUNC-02, NA-FUNC-03, NA-EXP-01, NA-LOG-01,
311
- MG-TRIG-01, NA-FIN-01, NA-FIN-02, NA-FIN-04,
312
- NA-LOGIC-01
313
- Business Math: BM-FDP-01, BM-FDP-02, BM-BUS-01, BM-BUS-02,
314
- BM-COMM-01, BM-COMM-02, BM-SW-01, BM-SW-03, BM-SW-04
315
- Statistics: SP-RV-01, SP-RV-02, SP-NORM-01, SP-NORM-02,
316
- SP-SAMP-01, SP-SAMP-03, SP-HYP-01
317
- Finite Math: FM1-MAT-01, FM2-PROB-01, FM2-PROB-02
318
-
319
- {previous_block}{variance_block}DIFFICULTY DISTRIBUTION (across all 15 questions):
320
- - Easy (Bloom: remembering / understanding): 6 questions (40%)
321
- - Medium (Bloom: applying / analyzing): 6 questions (40%)
322
- - Hard (Bloom: evaluating / creating): 3 questions (20%)
323
-
324
- QUESTION RULES:
325
- 1. All questions are 4-option multiple choice (A, B, C, D).
326
- 2. Use Filipino real-life context: peso amounts, Filipino names
327
- (Juan, Maria, Jose), Philippine institutions (SSS, PhilHealth,
328
- Pag-IBIG, BIR, BDO, local schools, SM malls).
329
- 3. Never use trick questions. Wrong options must be plausible but clearly
330
- incorrect to a student who knows the concept.
331
- 4. Include a solution_hint (1-2 sentences) -- this is for the backend
332
- scoring engine ONLY. NEVER include it in the client response.
333
- 5. Cover as many different competency codes as possible across 15 items.
334
- Do not repeat the same competency code more than twice.
335
-
336
- OUTPUT FORMAT (strict JSON array, no extra text, no markdown):
337
- [
338
- {{
339
- "question_id": "DX-<uuid>",
340
- "competency_code": "BM-SW-03",
341
- "domain": "Business Mathematics",
342
- "topic": "Mandatory Deductions",
343
- "difficulty": "medium",
344
- "bloom_level": "applying",
345
- "question_text": "...",
346
- "options": {{"A": "...", "B": "...", "C": "...", "D": "..."}},
347
- "correct_answer": "C",
348
- "solution_hint": "Compute SSS contribution using the prescribed table...",
349
- "curriculum_reference": "SDO Navotas Bus. Math SHS 1st Sem - Salaries and Wages"
350
- }}
351
- ]
352
- """
353
-
354
-
355
- async def _call_deepseek(system_prompt: str, user_message: str, temperature: float = 0.7) -> str:
356
- try:
357
- client = get_deepseek_client()
358
- response = client.chat.completions.create(
359
- model=CHAT_MODEL,
360
- messages=[
361
- {"role": "system", "content": system_prompt},
362
- {"role": "user", "content": user_message},
363
- ],
364
- temperature=temperature,
365
- response_format={"type": "json_object"},
366
- )
367
- return response.choices[0].message.content or ""
368
- except Exception as e:
369
- logger.error(f"DeepSeek API error: {e}")
370
- raise HTTPException(status_code=500, detail="AI model unavailable. Please try again later.")
371
-
372
-
373
- def _parse_questions_response(raw_response: str) -> List[Dict[str, Any]]:
374
- try:
375
- data = json.loads(raw_response)
376
- if isinstance(data, dict):
377
- for key in ("questions", "items", "data", "results"):
378
- if key in data and isinstance(data[key], list):
379
- return data[key]
380
- for key, value in data.items():
381
- if isinstance(value, list) and len(value) > 0 and isinstance(value[0], dict):
382
- if "question_text" in value[0]:
383
- return value
384
- if isinstance(data, list):
385
- return data
386
- except json.JSONDecodeError:
387
- pass
388
-
389
- import re
390
- match = re.search(r'\[.*\]', raw_response, re.DOTALL)
391
- if match:
392
- try:
393
- return json.loads(match.group())
394
- except json.JSONDecodeError:
395
- pass
396
-
397
- raise ValueError("Could not parse questions from AI response")
398
-
399
-
400
- async def _generate_questions(
401
- strand: str,
402
- grade_level: str,
403
- user_id: str = "",
404
- firestore_client=None,
405
- ) -> tuple[str, List[Dict[str, Any]]]:
406
- test_id = f"DX-{uuid.uuid4().hex[:12]}"
407
-
408
- # Generate variance seed based on user's attempt count and fetch previous questions
409
- variance_seed = 0
410
- previous_questions: list[str] = []
411
-
412
- if firestore_client and user_id:
413
- try:
414
- attempts_ref = (
415
- firestore_client.collection("assessmentResults")
416
- .document(user_id)
417
- .collection("attempts")
418
- )
419
- attempts = attempts_ref.stream()
420
- attempt_count = sum(1 for _ in attempts)
421
- variance_seed = int(time.time()) % 10000 + attempt_count * 137
422
- previous_questions = await _get_previous_questions(user_id, firestore_client)
423
- except Exception:
424
- pass
425
-
426
- rag_context = _build_rag_context(strand)
427
- system_prompt = _build_system_prompt(
428
- strand,
429
- grade_level,
430
- rag_context,
431
- variance_seed=variance_seed,
432
- previous_questions=previous_questions,
433
- )
434
- user_message = f"Generate 15 diagnostic questions for a Grade 11 {strand} student."
435
-
436
- for attempt in range(2):
437
- temperature = 0.7 if attempt == 0 else 0.3
438
- try:
439
- raw_response = await _call_deepseek(system_prompt, user_message, temperature)
440
- questions = _parse_questions_response(raw_response)
441
- if questions:
442
- return test_id, questions[:15]
443
- except ValueError:
444
- if attempt == 0:
445
- logger.warning("Malformed JSON from DeepSeek, retrying with temperature=0.3")
446
- continue
447
- raise
448
-
449
- raise HTTPException(status_code=500, detail="Assessment generation failed. Please try again.")
450
-
451
-
452
- async def _store_diagnostic_session(
453
- firestore_client: Any,
454
- user_id: str,
455
- test_id: str,
456
- strand: str,
457
- grade_level: str,
458
- questions: List[Dict[str, Any]],
459
- ) -> bool:
460
- try:
461
- doc_ref = (
462
- firestore_client.collection("diagnosticSessions")
463
- .document(test_id)
464
- )
465
- doc_ref.set({
466
- "testId": test_id,
467
- "userId": user_id,
468
- "generatedAt": fs.SERVER_TIMESTAMP,
469
- "strand": strand,
470
- "gradeLevel": grade_level,
471
- "questions": questions,
472
- "status": "in_progress",
473
- })
474
- return True
475
- except Exception as e:
476
- logger.error(f"Failed to store diagnostic session: {e}")
477
- return False
478
-
479
-
480
- def _strip_answers(questions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
481
- stripped = []
482
- for q in questions:
483
- stripped.append({
484
- "question_id": q.get("question_id", ""),
485
- "competency_code": q.get("competency_code", ""),
486
- "domain": q.get("domain", ""),
487
- "topic": q.get("topic", ""),
488
- "difficulty": q.get("difficulty", ""),
489
- "bloom_level": q.get("bloom_level", ""),
490
- "question_text": q.get("question_text", ""),
491
- "options": q.get("options", {}),
492
- "curriculum_reference": q.get("curriculum_reference", ""),
493
- })
494
- return stripped
495
-
496
-
497
- # ─── ENDPOINT 1: Generate Diagnostic ────��───────────────────────────
498
-
499
- @router.post("/generate", response_model=DiagnosticGenerateResponse)
500
- async def generate_diagnostic(request: DiagnosticGenerateRequest, req: Request):
501
- user = getattr(req.state, "user", None)
502
- if not user or not getattr(user, "uid", None):
503
- raise HTTPException(status_code=401, detail="Authentication required")
504
-
505
- try:
506
- firestore_client = fs.client()
507
- test_id, questions = await _generate_questions(
508
- request.strand,
509
- request.grade_level,
510
- user_id=user.uid,
511
- firestore_client=firestore_client,
512
- )
513
- except HTTPException:
514
- raise
515
- except Exception as e:
516
- logger.error(f"Generation error: {e}\n{traceback.format_exc()}")
517
- raise HTTPException(status_code=500, detail="Assessment generation failed. Please try again.")
518
-
519
- try:
520
- stored = await _store_diagnostic_session(
521
- firestore_client,
522
- user.uid,
523
- test_id,
524
- request.strand,
525
- request.grade_level,
526
- questions,
527
- )
528
- if not stored:
529
- raise HTTPException(status_code=503, detail="Session storage failed. Please try again.")
530
- except HTTPException:
531
- raise
532
- except Exception as e:
533
- logger.error(f"Could not store diagnostic session: {e}")
534
- raise HTTPException(status_code=503, detail="Database unavailable. Please try again.")
535
-
536
- client_questions = _strip_answers(questions)
537
-
538
- return DiagnosticGenerateResponse(
539
- test_id=test_id,
540
- questions=client_questions,
541
- total_items=len(client_questions),
542
- estimated_minutes=11.6,
543
- )
544
-
545
-
546
- # ─── ENDPOINT 2: Submit and Evaluate ─────────────────────────────────
547
-
548
- def _score_responses(stored_questions: List[Dict[str, Any]], responses: List[DiagnosticResponseItem]) -> tuple:
549
- question_map: Dict[str, Dict[str, Any]] = {}
550
- for q in stored_questions:
551
- question_map[q.get("question_id", "")] = q
552
-
553
- scored = []
554
- total_correct = 0
555
- domain_correct: Dict[str, int] = {}
556
- domain_total: Dict[str, int] = {}
557
- comp_attempts: Dict[str, List[bool]] = {}
558
-
559
- for resp in responses:
560
- question = question_map.get(resp.question_id, {})
561
- correct_answer = question.get("correct_answer", "")
562
- is_correct = (resp.student_answer.strip().upper() == correct_answer.strip().upper())
563
-
564
- domain = question.get("domain", "Unknown")
565
- competency_code = question.get("competency_code", "")
566
-
567
- if domain not in domain_correct:
568
- domain_correct[domain] = 0
569
- domain_total[domain] = 0
570
- domain_total[domain] += 1
571
- if is_correct:
572
- domain_correct[domain] += 1
573
- total_correct += 1
574
-
575
- if competency_code not in comp_attempts:
576
- comp_attempts[competency_code] = []
577
- comp_attempts[competency_code].append(is_correct)
578
-
579
- scored.append({
580
- "question_id": resp.question_id,
581
- "competency_code": competency_code,
582
- "domain": domain,
583
- "topic": question.get("topic", ""),
584
- "difficulty": question.get("difficulty", ""),
585
- "bloom_level": question.get("bloom_level", ""),
586
- "student_answer": resp.student_answer,
587
- "correct_answer": correct_answer,
588
- "is_correct": is_correct,
589
- "time_spent_seconds": resp.time_spent_seconds,
590
- })
591
-
592
- return scored, total_correct, domain_correct, domain_total, comp_attempts
593
-
594
-
595
- def _compute_domain_scores(domain_correct: Dict[str, int], domain_total: Dict[str, int]) -> Dict[str, Dict[str, Any]]:
596
- domain_scores = {}
597
- for domain in domain_total:
598
- correct = domain_correct.get(domain, 0)
599
- total = domain_total[domain]
600
- pct = (correct / total * 100) if total > 0 else 0
601
- mastery = "mastered" if pct >= 80 else "developing" if pct >= 60 else "beginning"
602
- domain_scores[domain] = {
603
- "correct": correct,
604
- "total": total,
605
- "percentage": round(pct, 1),
606
- "mastery_level": mastery,
607
- }
608
- return domain_scores
609
-
610
-
611
- def _compute_risk_profile(
612
- total_correct: int,
613
- total_items: int,
614
- scored_responses: List[Dict[str, Any]],
615
- domain_scores: Dict[str, Dict[str, Any]],
616
- ) -> Dict[str, Any]:
617
- overall_pct = (total_correct / total_items * 100) if total_items > 0 else 0
618
-
619
- mastered = [d for d, s in domain_scores.items() if s["mastery_level"] == "mastered"]
620
- developing = [d for d, s in domain_scores.items() if s["mastery_level"] == "developing"]
621
- beginning = [d for d, s in domain_scores.items() if s["mastery_level"] == "beginning"]
622
-
623
- critical_gaps = []
624
- for resp in scored_responses:
625
- code = resp.get("competency_code", "")
626
- if not code:
627
- continue
628
- attempts = [r for r in scored_responses if r.get("competency_code") == code]
629
- if len(attempts) >= 2 and not any(r.get("is_correct") for r in attempts):
630
- if code not in critical_gaps:
631
- critical_gaps.append(code)
632
-
633
- if overall_pct >= 75 and len(beginning) == 0:
634
- overall_risk = "low"
635
- elif overall_pct >= 55 or len(beginning) <= 2:
636
- overall_risk = "moderate"
637
- elif overall_pct >= 40 or len(beginning) <= 4:
638
- overall_risk = "high"
639
- else:
640
- overall_risk = "critical"
641
-
642
- suggested_path = []
643
- for code in critical_gaps:
644
- if code not in suggested_path:
645
- suggested_path.append(code)
646
- for domain in beginning:
647
- for prefix in ["NA", "BM", "SP", "FM"]:
648
- if domain.upper().startswith(prefix) or any(
649
- s.upper().startswith(prefix) for s in [domain]
650
- ):
651
- for comp_code in LEARNING_PATH_ORDER.get(prefix, []):
652
- if comp_code not in suggested_path:
653
- suggested_path.append(comp_code)
654
- break
655
- for domain in developing:
656
- for prefix in ["NA", "BM", "SP", "FM"]:
657
- if any(c.startswith(prefix) for c in COMPETENCY_REGISTRY):
658
- for comp_code in LEARNING_PATH_ORDER.get(prefix, []):
659
- if comp_code not in suggested_path:
660
- suggested_path.append(comp_code)
661
-
662
- interventions = {
663
- "low": "Great job! You have a solid foundation. Keep practicing to maintain your skills!",
664
- "moderate": "You're making good progress. Focus on the topics where you need more practice. Kaya mo yan!",
665
- "high": "Don't worry! With focused practice on your weak areas, you'll improve quickly.",
666
- "critical": "Let's work on this together. Start with the basics and build up your confidence step by step.",
667
- }
668
-
669
- return {
670
- "overall_risk": overall_risk,
671
- "overall_score_percent": round(overall_pct, 1),
672
- "mastery_summary": {
673
- "mastered": mastered,
674
- "developing": developing,
675
- "beginning": beginning,
676
- },
677
- "weak_domains": beginning,
678
- "critical_gaps": critical_gaps,
679
- "recommended_intervention": interventions.get(overall_risk, interventions["moderate"]),
680
- "suggested_learning_path": suggested_path[:20],
681
- }
682
-
683
-
684
- async def _save_results(
685
- firestore_client: Any,
686
- user_id: str,
687
- test_id: str,
688
- strand: str,
689
- grade_level: str,
690
- scored_responses: List[Dict[str, Any]],
691
- domain_scores: Dict[str, Dict[str, Any]],
692
- risk_profile: Dict[str, Any],
693
- total_correct: int,
694
- total_items: int,
695
- ) -> None:
696
- try:
697
- overall_pct = round(total_correct / total_items * 100, 1) if total_items > 0 else 0
698
-
699
- firestore_client.collection("diagnosticResults").document(user_id).set({
700
- "userId": user_id,
701
- "testId": test_id,
702
- "takenAt": fs.SERVER_TIMESTAMP,
703
- "strand": strand,
704
- "gradeLevel": grade_level,
705
- "status": "completed",
706
- "totalItems": total_items,
707
- "totalScore": total_correct,
708
- "percentageScore": overall_pct,
709
- "responses": scored_responses,
710
- "domainScores": domain_scores,
711
- "riskProfile": risk_profile,
712
- })
713
-
714
- mastered_count = len(risk_profile.get("mastery_summary", {}).get("mastered", []))
715
-
716
- firestore_client.collection("studentProgress").document(user_id).collection("stats").document("main").set({
717
- "learning_path": risk_profile.get("suggested_learning_path", []),
718
- "current_topic_index": 0,
719
- "total_xp": fs.Increment(50 + mastered_count * 10),
720
- "badges": fs.ArrayUnion(["first_assessment"]),
721
- "topics_mastered": mastered_count,
722
- "diagnostic_completed": True,
723
- "overall_risk": risk_profile.get("overall_risk", "moderate"),
724
- }, merge=True)
725
-
726
- firestore_client.collection("diagnosticSessions").document(test_id).update({
727
- "status": "completed",
728
- "completedAt": fs.SERVER_TIMESTAMP,
729
- })
730
-
731
- except Exception as e:
732
- logger.error(f"Firestore save error: {e}")
733
- raise
734
-
735
-
736
- @router.post("/submit", response_model=DiagnosticSubmitResponse)
737
- async def submit_diagnostic(request: DiagnosticSubmitRequest, req: Request):
738
- user = getattr(req.state, "user", None)
739
- if not user or not getattr(user, "uid", None):
740
- raise HTTPException(status_code=401, detail="Authentication required")
741
-
742
- try:
743
- firestore_client = fs.client()
744
- except Exception as e:
745
- raise HTTPException(status_code=503, detail="Database unavailable")
746
-
747
- try:
748
- session_doc = firestore_client.collection("diagnosticSessions").document(request.test_id).get()
749
- if not session_doc.exists:
750
- raise HTTPException(status_code=404, detail="Diagnostic session not found")
751
-
752
- session_data = session_doc.to_dict() or {}
753
- stored_questions = session_data.get("questions", [])
754
- strand = session_data.get("strand", "STEM")
755
- grade_level = session_data.get("gradeLevel", "Grade 11")
756
-
757
- if not stored_questions:
758
- raise HTTPException(status_code=400, detail="No questions found for this session")
759
- except HTTPException:
760
- raise
761
- except Exception as e:
762
- logger.error(f"Session retrieval error: {e}")
763
- raise HTTPException(status_code=500, detail="Failed to retrieve diagnostic session")
764
-
765
- scored_responses, total_correct, domain_correct, domain_total, _ = _score_responses(
766
- stored_questions, request.responses
767
- )
768
-
769
- total_items = len(stored_questions)
770
- domain_scores = _compute_domain_scores(domain_correct, domain_total)
771
- risk_profile = _compute_risk_profile(total_correct, total_items, scored_responses, domain_scores)
772
-
773
- await _save_results(
774
- firestore_client,
775
- user.uid,
776
- request.test_id,
777
- strand,
778
- grade_level,
779
- scored_responses,
780
- domain_scores,
781
- risk_profile,
782
- total_correct,
783
- total_items,
784
- )
785
-
786
- mastered_count = len(risk_profile.get("mastery_summary", {}).get("mastered", []))
787
-
788
- return DiagnosticSubmitResponse(
789
- success=True,
790
- overall_risk=risk_profile["overall_risk"],
791
- overall_score_percent=risk_profile["overall_score_percent"],
792
- mastery_summary=MasterySummary(**risk_profile["mastery_summary"]),
793
- recommended_intervention=risk_profile["recommended_intervention"],
794
- xp_earned=50 + mastered_count * 10,
795
- badge_unlocked="first_assessment",
796
- redirect_to="/dashboard",
797
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routes/quiz_battle.py DELETED
@@ -1,205 +0,0 @@
1
- """
2
- Quiz Battle API Routes.
3
-
4
- Endpoints:
5
- - POST /api/quiz-battle/generate → Generate varied questions for a battle session
6
- - POST /api/quiz-battle/ingest-pdf → Trigger PDF ingestion (teacher/admin)
7
- - GET /api/quiz-battle/bank-status → List processed PDFs (teacher/admin)
8
- """
9
-
10
- import os
11
- from typing import List, Optional, Dict, Any
12
- from datetime import datetime, timezone
13
-
14
- from fastapi import APIRouter, Request, HTTPException, Depends
15
- from pydantic import BaseModel, Field
16
-
17
- from rag.pdf_ingestion import ingest_pdf, IngestionResult
18
- from services.question_bank_service import get_questions_for_battle, cache_session_questions, get_cached_session
19
- from services.variance_engine import apply_variance
20
-
21
- router = APIRouter(prefix="/api/quiz-battle", tags=["quiz-battle"])
22
-
23
-
24
- # ── Pydantic Models ──────────────────────────────────────────────────
25
-
26
- class GenerateRequest(BaseModel):
27
- grade_level: int = Field(..., ge=7, le=12)
28
- topic: str = Field(..., min_length=1)
29
- question_count: int = Field(default=10, ge=1, le=50)
30
- session_id: str = Field(..., min_length=1)
31
- player_ids: List[str] = Field(default_factory=list)
32
-
33
-
34
- class GenerateResponse(BaseModel):
35
- questions: List[Dict[str, Any]]
36
- session_id: str
37
-
38
-
39
- class IngestPdfRequest(BaseModel):
40
- storage_path: str = Field(..., min_length=1)
41
- grade_level: int = Field(..., ge=7, le=12)
42
- topic: str = Field(..., min_length=1)
43
- force_reingest: bool = False
44
-
45
-
46
- class IngestPdfResponse(BaseModel):
47
- status: str
48
- filename: str
49
- question_count: int
50
- grade_level: int
51
- topic: str
52
- storage_path: str
53
- timestamp: datetime
54
-
55
-
56
- class BankStatusItem(BaseModel):
57
- filename: str
58
- processed: bool
59
- timestamp: Optional[datetime]
60
- question_count: int
61
- grade_level: int
62
- topic: str
63
- storage_path: str
64
-
65
-
66
- class BankStatusResponse(BaseModel):
67
- pdfs: List[BankStatusItem]
68
-
69
-
70
- # ── Helper ───────────────────────────────────────────────────────────
71
-
72
- def _get_current_user(request: Request):
73
- user = getattr(request.state, "user", None)
74
- if user is None:
75
- raise HTTPException(status_code=401, detail="Authentication required")
76
- return user
77
-
78
-
79
- def _is_internal_request(request: Request) -> bool:
80
- """Check if request is from an internal service (Cloud Functions)."""
81
- internal_secret = request.headers.get("X-Internal-Service")
82
- expected = os.getenv("QUIZ_BATTLE_INTERNAL_SECRET")
83
- if expected and internal_secret == expected:
84
- return True
85
- return False
86
-
87
-
88
- # ── Endpoints ────────────────────────────────────────────────────────
89
-
90
- @router.post("/generate", response_model=GenerateResponse)
91
- async def generate_questions(
92
- body: GenerateRequest,
93
- request: Request,
94
- ):
95
- """
96
- Generate varied questions for a quiz battle session.
97
-
98
- Returns questions with choices but WITHOUT correct_answer (unless called
99
- by an internal service with X-Internal-Service header).
100
- """
101
- # 1. Fetch base questions
102
- questions = await get_questions_for_battle(
103
- body.grade_level,
104
- body.topic,
105
- body.question_count,
106
- )
107
-
108
- if not questions:
109
- raise HTTPException(
110
- status_code=404,
111
- detail=f"No questions found for grade {body.grade_level}, topic '{body.topic}'",
112
- )
113
-
114
- # 2. Apply variance (with 24h cache)
115
- varied = await apply_variance(questions, body.session_id)
116
-
117
- # 3. Cache session metadata
118
- await cache_session_questions(
119
- body.session_id,
120
- varied,
121
- body.player_ids,
122
- body.grade_level,
123
- body.topic,
124
- )
125
-
126
- # 4. Prepare response
127
- is_internal = _is_internal_request(request)
128
- response_questions = []
129
- for q in varied:
130
- q_copy = dict(q)
131
- if not is_internal:
132
- q_copy.pop("correct_answer", None)
133
- response_questions.append(q_copy)
134
-
135
- return GenerateResponse(questions=response_questions, session_id=body.session_id)
136
-
137
-
138
- @router.post("/ingest-pdf", response_model=IngestPdfResponse)
139
- async def ingest_pdf_endpoint(
140
- body: IngestPdfRequest,
141
- user=Depends(_get_current_user),
142
- ):
143
- """
144
- Trigger PDF ingestion into the question bank.
145
-
146
- Requires teacher or admin role.
147
- """
148
- if user.role not in ("teacher", "admin"):
149
- raise HTTPException(status_code=403, detail="Teacher or admin access required")
150
-
151
- try:
152
- result = await ingest_pdf(
153
- storage_path=body.storage_path,
154
- grade_level=body.grade_level,
155
- topic=body.topic,
156
- force_reingest=body.force_reingest,
157
- )
158
- except FileNotFoundError as e:
159
- raise HTTPException(status_code=404, detail=str(e))
160
- except ValueError as e:
161
- raise HTTPException(status_code=400, detail=str(e))
162
- except Exception as e:
163
- raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}")
164
-
165
- return IngestPdfResponse(
166
- status="processed" if result.processed else "skipped",
167
- filename=result.filename,
168
- question_count=result.question_count,
169
- grade_level=result.grade_level,
170
- topic=result.topic,
171
- storage_path=result.storage_path,
172
- timestamp=result.timestamp,
173
- )
174
-
175
-
176
- @router.get("/bank-status", response_model=BankStatusResponse)
177
- async def bank_status(
178
- user=Depends(_get_current_user),
179
- ):
180
- """
181
- Get the status of all processed PDFs in the question bank.
182
-
183
- Requires teacher or admin role.
184
- """
185
- if user.role not in ("teacher", "admin"):
186
- raise HTTPException(status_code=403, detail="Teacher or admin access required")
187
-
188
- from google.cloud import firestore
189
- db = firestore.Client(project=os.getenv("FIREBASE_AUTH_PROJECT_ID", "mathpulse-ai-2026"))
190
-
191
- docs = db.collection("pdf_processing_status").stream()
192
- pdfs = []
193
- for doc in docs:
194
- data = doc.to_dict()
195
- pdfs.append(BankStatusItem(
196
- filename=doc.id,
197
- processed=data.get("processed", False),
198
- timestamp=data.get("timestamp"),
199
- question_count=data.get("question_count", 0),
200
- grade_level=data.get("grade_level", 0),
201
- topic=data.get("topic", ""),
202
- storage_path=data.get("storage_path", ""),
203
- ))
204
-
205
- return BankStatusResponse(pdfs=pdfs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routes/quiz_generation_routes.py DELETED
@@ -1,356 +0,0 @@
1
- """
2
- Unified Quiz Generation Routes.
3
-
4
- Generates dynamic quiz questions using DeepSeek AI + RAG curriculum context.
5
- Used by: lesson practice quizzes, module quizzes, and quiz battle.
6
-
7
- When new PDFs are ingested into the vectorstore, this endpoint automatically
8
- picks up the new content via RAG retrieval.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import json
14
- import logging
15
- import random
16
- import re
17
- from typing import Any, Dict, List, Optional
18
-
19
- from fastapi import APIRouter, HTTPException, Request
20
- from pydantic import BaseModel, Field
21
-
22
- from rag.curriculum_rag import (
23
- retrieve_curriculum_context,
24
- summarize_retrieval_confidence,
25
- )
26
- from services.inference_client import (
27
- InferenceRequest,
28
- create_default_client,
29
- get_model_for_task,
30
- )
31
-
32
- logger = logging.getLogger("mathpulse.quiz_generation")
33
- router = APIRouter(prefix="/api/quiz", tags=["quiz-generation"])
34
-
35
- _inference_client = None
36
-
37
-
38
- def _get_inference_client():
39
- global _inference_client
40
- if _inference_client is None:
41
- _inference_client = create_default_client()
42
- return _inference_client
43
-
44
-
45
- # ── Request/Response Models ────────────────────────────────────────────
46
-
47
- class QuizGenerationRequest(BaseModel):
48
- topic: str = Field(..., min_length=1, description="Lesson topic or competency")
49
- subject: str = Field(..., min_length=1, description="Subject name (e.g., 'General Mathematics')")
50
- lessonTitle: Optional[str] = Field(default=None, description="Full lesson title")
51
- questionCount: int = Field(default=6, ge=1, le=20, description="Number of questions to generate")
52
- questionTypes: List[str] = Field(
53
- default=["multiple-choice", "true-false", "fill-in-blank"],
54
- description="Question types to include",
55
- )
56
- difficulty: str = Field(default="medium", pattern="^(easy|medium|hard)$")
57
- quarter: Optional[int] = Field(default=1, ge=1, le=4)
58
- moduleId: Optional[str] = Field(default=None)
59
- lessonId: Optional[str] = Field(default=None)
60
- competencyCode: Optional[str] = Field(default=None)
61
- storagePath: Optional[str] = Field(default=None)
62
- userId: Optional[str] = Field(default=None)
63
- varianceSeed: Optional[int] = Field(default=None, description="Random seed for variance across generations")
64
-
65
-
66
- class QuizQuestion(BaseModel):
67
- id: int
68
- type: str
69
- question: str
70
- options: Optional[List[str]] = None
71
- correctAnswer: str
72
- explanation: str
73
-
74
-
75
- class QuizGenerationResponse(BaseModel):
76
- questions: List[QuizQuestion]
77
- retrievalConfidence: Dict[str, Any]
78
- sourceChunks: int
79
- generatedAt: str
80
-
81
-
82
- # ── Prompt Builder ─────────────────────────────────────────────────────
83
-
84
- def _build_quiz_generation_prompt(
85
- topic: str,
86
- subject: str,
87
- lesson_title: Optional[str],
88
- question_count: int,
89
- question_types: List[str],
90
- difficulty: str,
91
- retrieved_context: str,
92
- variance_seed: Optional[int] = None,
93
- ) -> str:
94
- """Build the DeepSeek prompt for quiz generation with variance."""
95
-
96
- # Build variance instruction based on seed
97
- variance_instruction = ""
98
- if variance_seed is not None:
99
- variance_instruction = f"""
100
- 8. VARIANCE REQUIREMENT: Use seed {variance_seed} to ensure variety. Generate DIFFERENT questions each time.
101
- - Paraphrase concepts in fresh ways
102
- - Use different numerical values and scenarios
103
- - Vary question phrasing and structure
104
- - Avoid repeating similar question patterns"""
105
-
106
- return f"""You are a DepEd-aligned mathematics quiz generator for Filipino Senior High School students (Grades 11-12).
107
-
108
- Given the following curriculum context about "{topic}" from {subject}, generate {question_count} {difficulty}-difficulty quiz questions.
109
-
110
- ## Retrieved Curriculum Context
111
- {retrieved_context}
112
-
113
- ## Instructions
114
- 1. Generate exactly {question_count} questions covering the topic above.
115
- 2. Question types to use: {', '.join(question_types)}
116
- 3. DISTRIBUTION (for {question_count} questions):
117
- - 2 items: Recall and Basics (simple recall, definitions, fundamental facts)
118
- - 4 items: Direct Application (real-world context with pesos, jeepney, sari-sari store, etc.)
119
- - 3 items: Mixed/Interleaved Problems (combine concepts, multi-step reasoning)
120
- - 1 item: Metacognitive/Reflective (explain reasoning, justify approach, identify errors)
121
- 4. Difficulty: {difficulty} — appropriate for Grade 11-12 Filipino STEM students.
122
- 5. Use Filipino-localized context where possible (pesos, jeepney, barangay, sari-sari store, etc.).
123
- 6. Each question must be mathematically accurate and curriculum-aligned.
124
- 7. Provide clear explanations for the correct answer.{variance_instruction}
125
-
126
- ## Question Type Rules
127
- - multiple-choice: 4 options (A/B/C/D format), exactly one correct answer
128
- - true-false: statement that is either True or False
129
- - fill-in-blank: question with a single numeric or short text answer
130
-
131
- ## Output Format
132
- Return ONLY a valid JSON array. No markdown, no extra text. Format:
133
- [
134
- {{
135
- "type": "multiple-choice",
136
- "question": "What is the derivative of f(x) = x³?",
137
- "options": ["2x²", "3x²", "x²", "3x"],
138
- "correctAnswer": "3x²",
139
- "explanation": "Using the power rule: d/dx(xⁿ) = nxⁿ⁻¹. So d/dx(x³) = 3x²."
140
- }},
141
- {{
142
- "type": "true-false",
143
- "question": "The sum of angles in a triangle is 180 degrees.",
144
- "options": ["True", "False"],
145
- "correctAnswer": "True",
146
- "explanation": "By the triangle angle sum theorem, the interior angles of any Euclidean triangle sum to 180°."
147
- }},
148
- {{
149
- "type": "fill-in-blank",
150
- "question": "If f(x) = 2x + 3, then f(4) = ___",
151
- "options": null,
152
- "correctAnswer": "11",
153
- "explanation": "Substitute x = 4: f(4) = 2(4) + 3 = 8 + 3 = 11."
154
- }}
155
- ]
156
-
157
- IMPORTANT:
158
- - Return ONLY the JSON array, no other text
159
- - Ensure correctAnswer exactly matches one of the options (for MC/TF)
160
- - For fill-in-blank, correctAnswer is the exact text that fills the blank
161
- - Generate FRESH, VARIED questions - no two questions should be identical or nearly identical
162
- - Questions should feel like they were created independently, not templated"""
163
-
164
-
165
- # ── Response Parser ────────────────────────────────────────────────────
166
-
167
- def _parse_quiz_response(text: str, expected_count: int) -> List[Dict[str, Any]]:
168
- """Parse and validate DeepSeek quiz generation response."""
169
- cleaned = text.strip()
170
-
171
- # Strip markdown fences
172
- cleaned = re.sub(r"^```json\s*", "", cleaned, flags=re.IGNORECASE)
173
- cleaned = re.sub(r"^```\s*", "", cleaned)
174
- cleaned = re.sub(r"\s*```$", "", cleaned)
175
- cleaned = cleaned.strip()
176
-
177
- try:
178
- questions = json.loads(cleaned)
179
- except json.JSONDecodeError as e:
180
- logger.error(f"Failed to parse quiz response as JSON: {e}")
181
- # Try to extract JSON array from text
182
- match = re.search(r"\[.*\]", cleaned, re.DOTALL)
183
- if match:
184
- try:
185
- questions = json.loads(match.group())
186
- except json.JSONDecodeError:
187
- raise ValueError(f"Invalid JSON in quiz response: {e}")
188
- else:
189
- raise ValueError(f"No JSON array found in quiz response")
190
-
191
- if not isinstance(questions, list):
192
- raise ValueError("Quiz response is not a JSON array")
193
-
194
- validated = []
195
- for i, q in enumerate(questions):
196
- if not isinstance(q, dict):
197
- continue
198
-
199
- # Ensure required fields
200
- if "question" not in q or "correctAnswer" not in q:
201
- continue
202
-
203
- # Normalize field names
204
- normalized = {
205
- "id": i + 1,
206
- "type": q.get("type", "multiple-choice"),
207
- "question": q["question"],
208
- "correctAnswer": q["correctAnswer"],
209
- "explanation": q.get("explanation", ""),
210
- }
211
-
212
- # Handle options
213
- if "options" in q and q["options"]:
214
- normalized["options"] = q["options"]
215
- elif "choices" in q and q["choices"]:
216
- normalized["options"] = q["choices"]
217
- else:
218
- # For true-false, auto-populate options
219
- if normalized["type"] == "true-false":
220
- normalized["options"] = ["True", "False"]
221
- else:
222
- normalized["options"] = None
223
-
224
- validated.append(normalized)
225
-
226
- if len(validated) < min(expected_count, 3):
227
- raise ValueError(f"Only {len(validated)} valid questions parsed, expected at least {min(expected_count, 3)}")
228
-
229
- return validated[:expected_count]
230
-
231
-
232
- # ── Variance Application ───────────────────────────────────────────────
233
-
234
- def _apply_variance(questions: List[Dict[str, Any]], seed: int) -> List[Dict[str, Any]]:
235
- """Apply deterministic variance to questions (shuffle choices, etc.)."""
236
- rng = random.Random(seed)
237
-
238
- for q in questions:
239
- # Shuffle multiple-choice options while tracking correct answer
240
- if q.get("type") == "multiple-choice" and q.get("options"):
241
- options = q["options"].copy()
242
- correct = q["correctAnswer"]
243
-
244
- # Only shuffle if correct answer is in options
245
- if correct in options:
246
- rng.shuffle(options)
247
- q["options"] = options
248
- q["correctAnswer"] = correct # Keep original correct answer text
249
-
250
- return questions
251
-
252
-
253
- # ── Endpoints ──────────────────────────────────────────────────────────
254
-
255
- @router.post("/generate", response_model=QuizGenerationResponse)
256
- async def generate_quiz(request: QuizGenerationRequest):
257
- """
258
- Generate a dynamic quiz using DeepSeek AI + RAG curriculum context.
259
-
260
- This endpoint retrieves relevant curriculum chunks from the vectorstore,
261
- then calls DeepSeek to generate varied quiz questions based on that context.
262
- When new PDFs are ingested, they automatically become available via RAG.
263
- """
264
- try:
265
- # 1. Retrieve curriculum context via RAG
266
- query = request.lessonTitle or request.topic
267
- chunks = retrieve_curriculum_context(
268
- query=query,
269
- subject=request.subject,
270
- quarter=request.quarter,
271
- module_id=request.moduleId,
272
- lesson_id=request.lessonId,
273
- competency_code=request.competencyCode,
274
- storage_path=request.storagePath,
275
- top_k=8,
276
- )
277
-
278
- if not chunks:
279
- logger.warning(f"No curriculum chunks found for topic '{request.topic}' in subject '{request.subject}'")
280
- raise HTTPException(
281
- status_code=404,
282
- detail=f"No curriculum content found for topic '{request.topic}'. Please ensure PDFs are ingested.",
283
- )
284
-
285
- # Shuffle retrieved chunks for variance BEFORE formatting prompt context
286
- # This ensures different lessons → different curriculum context → different generated questions
287
- seed = request.varianceSeed if request.varianceSeed else hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}:{request.userId or 'anon'}") % (2**32)
288
- rng = random.Random(seed)
289
- rng.shuffle(chunks) # In-place shuffle for deterministic variety per seed
290
-
291
- # Format retrieved chunks for the prompt
292
- formatted_context = "\n\n---\n\n".join(
293
- f"[Source: {chunk.get('metadata', {}).get('source_file', 'Unknown')}, Page {chunk.get('metadata', {}).get('page', 'N/A')}]\n{chunk.get('document', '')}"
294
- for chunk in chunks
295
- )
296
-
297
- confidence = summarize_retrieval_confidence(chunks)
298
-
299
- # 2. Build generation prompt
300
- prompt = _build_quiz_generation_prompt(
301
- topic=request.topic,
302
- subject=request.subject,
303
- lesson_title=request.lessonTitle,
304
- question_count=request.questionCount,
305
- question_types=request.questionTypes,
306
- difficulty=request.difficulty,
307
- retrieved_context=formatted_context,
308
- variance_seed=request.varianceSeed,
309
- )
310
-
311
- # 3. Call DeepSeek with higher temperature for variance
312
- inference_request = InferenceRequest(
313
- messages=[
314
- {"role": "system", "content": "You are a precise DepEd-aligned curriculum quiz generator. Generate FRESH, VARIED questions each time - do not repeat patterns."},
315
- {"role": "user", "content": prompt},
316
- ],
317
- task_type="quiz_generation",
318
- max_new_tokens=3000,
319
- temperature=0.7, # Higher temp for variance
320
- top_p=0.9,
321
- )
322
-
323
- raw_response = _get_inference_client().generate_from_messages(inference_request)
324
-
325
- # 4. Parse response
326
- questions = _parse_quiz_response(raw_response, request.questionCount)
327
-
328
- # 5. Apply variance (shuffle options) with user-based seed for consistency
329
- seed = request.varianceSeed if request.varianceSeed else hash(f"{request.topic}:{request.subject}:{request.lessonTitle or ''}:{request.userId or 'anon'}") % (2**32)
330
- varied_questions = _apply_variance(questions, seed)
331
-
332
- # 6. Build response
333
- return QuizGenerationResponse(
334
- questions=[QuizQuestion(**q) for q in varied_questions],
335
- retrievalConfidence=confidence,
336
- sourceChunks=len(chunks),
337
- generatedAt=__import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat(),
338
- )
339
-
340
- except HTTPException:
341
- raise
342
- except Exception as e:
343
- logger.exception("Quiz generation failed")
344
- raise HTTPException(status_code=500, detail=f"Quiz generation failed: {str(e)}")
345
-
346
-
347
- @router.get("/health")
348
- async def quiz_generation_health():
349
- """Check quiz generation service health."""
350
- model = get_model_for_task("quiz_generation")
351
- return {
352
- "status": "ok",
353
- "activeModel": model,
354
- "endpoint": "/api/quiz/generate",
355
- "features": ["rag-retrieval", "deepseek-generation", "choice-shuffling", "auto-pdf-updates"],
356
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routes/rag_routes.py CHANGED
@@ -2,8 +2,6 @@ from __future__ import annotations
2
 
3
  import json
4
  import logging
5
- import os
6
- import re
7
  from datetime import datetime, timezone
8
  from threading import Lock
9
  from typing import Any, Dict, List, Optional
@@ -11,28 +9,21 @@ from typing import Any, Dict, List, Optional
11
  from fastapi import APIRouter, HTTPException, Request
12
  from pydantic import BaseModel, Field
13
 
14
- from services.inference_client import (
15
- InferenceRequest,
16
- create_default_client,
17
- is_sequential_model,
18
- get_model_for_task,
19
- )
20
  from rag.curriculum_rag import (
21
  build_analysis_curriculum_context,
22
  build_lesson_prompt,
23
  build_lesson_query,
24
  build_problem_generation_prompt,
25
- format_retrieved_chunks,
26
  retrieve_curriculum_context,
27
- retrieve_lesson_pdf_context,
28
  summarize_retrieval_confidence,
29
  )
30
- from rag.vectorstore_loader import get_vectorstore_health, 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,12 +41,7 @@ def _get_inference_client():
50
  return _inference_client
51
 
52
 
53
- async def _generate_text(
54
- prompt: str,
55
- task_type: str,
56
- max_new_tokens: int = 900,
57
- enable_thinking: bool = False,
58
- ) -> str:
59
  request = InferenceRequest(
60
  messages=[
61
  {"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
@@ -65,7 +51,6 @@ async def _generate_text(
65
  max_new_tokens=max_new_tokens,
66
  temperature=0.2,
67
  top_p=0.9,
68
- enable_thinking=enable_thinking,
69
  )
70
  return _get_inference_client().generate_from_messages(request)
71
 
@@ -103,21 +88,6 @@ def _log_rag_usage(
103
  logger.warning("rag_usage logging skipped: %s", exc)
104
 
105
 
106
- def _strip_thinking_and_parse(text: str) -> dict:
107
- cleaned = text.strip()
108
- cleaned = re.sub(r" </think>", "", cleaned, flags=re.DOTALL).strip()
109
- if "{" in cleaned and "}" in cleaned:
110
- try:
111
- start = cleaned.find("{")
112
- end = cleaned.rfind("}") + 1
113
- parsed = json.loads(cleaned[start:end])
114
- if isinstance(parsed, dict):
115
- return parsed
116
- except Exception:
117
- pass
118
- return {"explanation": text}
119
-
120
-
121
  class RagLessonRequest(BaseModel):
122
  topic: str
123
  subject: str
@@ -127,10 +97,6 @@ class RagLessonRequest(BaseModel):
127
  moduleUnit: Optional[str] = None
128
  learnerLevel: Optional[str] = None
129
  userId: Optional[str] = None
130
- moduleId: Optional[str] = None
131
- lessonId: Optional[str] = None
132
- competencyCode: Optional[str] = None
133
- storagePath: Optional[str] = None
134
 
135
 
136
  class RagProblemRequest(BaseModel):
@@ -149,8 +115,6 @@ class RagAnalysisContextRequest(BaseModel):
149
 
150
  @router.get("/health")
151
  async def rag_health():
152
- active_model = get_model_for_task("rag_lesson")
153
- is_seq = is_sequential_model(active_model)
154
  try:
155
  health = get_vectorstore_health()
156
  return {
@@ -158,8 +122,6 @@ async def rag_health():
158
  "chunkCount": health["chunkCount"],
159
  "subjects": health["subjects"],
160
  "lastIngested": datetime.now(timezone.utc).isoformat(),
161
- "activeModel": active_model,
162
- "isSequentialModel": is_seq,
163
  }
164
  except Exception as exc:
165
  return {
@@ -167,273 +129,68 @@ async def rag_health():
167
  "chunkCount": 0,
168
  "subjects": {},
169
  "lastIngested": None,
170
- "activeModel": active_model,
171
- "isSequentialModel": is_seq,
172
  "warning": str(exc),
173
  }
174
 
175
 
176
- def _fetch_youtube_videos(
177
- lesson_title: str,
178
- subject: str,
179
- competency: str,
180
- quarter: int,
181
- lesson_id: Optional[str] = None,
182
- ) -> List[Dict]:
183
- """Fetch up to 3 relevant YouTube videos for a lesson."""
184
- try:
185
- from services.youtube_service import get_video_search_results
186
- except ImportError:
187
- return []
188
- try:
189
- result = get_video_search_results(
190
- topic=lesson_title,
191
- subject=subject,
192
- lesson_context=competency,
193
- grade_level=f"Grade {quarter + 10}",
194
- lesson_id=lesson_id,
195
- max_results=3,
196
- )
197
- return result.get("videos", [])
198
- except Exception as e:
199
- logger.warning("YouTube video search failed: %s", e)
200
- return []
201
-
202
-
203
- def _ensure_7_sections(lesson_data: dict, lesson_title: str) -> dict:
204
- sections = lesson_data.get("sections", [])
205
- section_types = {s.get("type") for s in sections}
206
- required = ["introduction", "key_concepts", "video", "worked_examples", "important_notes", "try_it_yourself", "summary"]
207
-
208
- default_content = {
209
- "introduction": {"type": "introduction", "title": "Introduction", "content": f"Welcome to the lesson on {lesson_title}. This topic builds foundational skills for your mathematics journey."},
210
- "key_concepts": {"type": "key_concepts", "title": "Key Concepts", "content": f"The following key concepts are essential for mastering {lesson_title}:", "callouts": [{"type": "important", "text": "Review the curriculum PDF for detailed explanations of each concept."}]},
211
- "video": {"type": "video", "title": "Video Lesson", "content": "Watch the video explanation below to understand the concepts visually.", "videoId": "", "videoTitle": "", "videoChannel": "", "embedUrl": "", "thumbnailUrl": ""},
212
- "worked_examples": {"type": "worked_examples", "title": "Worked Examples", "examples": [{"problem": f"Sample problem for {lesson_title}", "steps": ["Step 1: Identify the given information.", "Step 2: Apply the appropriate formula or method.", "Step 3: Solve step-by-step.", "Step 4: Verify your answer."], "answer": "Solution will vary based on specific problem parameters."}]},
213
- "important_notes": {"type": "important_notes", "title": "Important Notes", "bulletPoints": [f"Always read problems carefully before solving {lesson_title} questions.", "Check your units and ensure consistency throughout calculations.", "Practice regularly to build fluency with these concepts."]},
214
- "try_it_yourself": {"type": "try_it_yourself", "title": "Try It Yourself", "practiceProblems": [{"question": f"Practice applying {lesson_title} concepts. Solve a similar problem from your textbook or worksheets.", "solution": "Compare your solution with the worked examples above. If stuck, re-read the key concepts section or ask your teacher for guidance."}]},
215
- "summary": {"type": "summary", "title": "Summary", "content": f"In this lesson on {lesson_title}, you explored key concepts, worked through examples, and practiced problem-solving techniques. Continue reviewing these materials and seek additional practice to strengthen your understanding."},
216
- }
217
-
218
- def _is_section_blank(section: dict, s_type: str) -> bool:
219
- """Check if a section has effectively no content."""
220
- if not section:
221
- return True
222
- text_content = (section.get("content") or "").strip()
223
- if s_type in ("introduction", "key_concepts", "video", "summary"):
224
- return len(text_content) < 10
225
- if s_type == "worked_examples":
226
- examples = section.get("examples") or []
227
- return not examples or all(not (ex.get("problem") or "").strip() for ex in examples)
228
- if s_type == "important_notes":
229
- bullets = section.get("bulletPoints") or []
230
- return not bullets or all(not (b or "").strip() for b in bullets)
231
- if s_type == "try_it_yourself":
232
- problems = section.get("practiceProblems") or []
233
- return not problems or all(not (p.get("question") or "").strip() for p in problems)
234
- return False
235
-
236
- filled = {}
237
- for req_type in required:
238
- for existing in sections:
239
- if existing.get("type") == req_type:
240
- filled[req_type] = existing
241
- break
242
- else:
243
- filled[req_type] = default_content[req_type]
244
-
245
- # Validate and replace blank sections with defaults
246
- for req_type in required:
247
- if _is_section_blank(filled[req_type], req_type):
248
- filled[req_type] = default_content[req_type]
249
-
250
- ordered = [filled[t] for t in required]
251
-
252
- for i, section in enumerate(ordered):
253
- s_type = section.get("type")
254
- if s_type == "key_concepts" and not section.get("callouts"):
255
- section["callouts"] = []
256
- if s_type == "worked_examples" and not section.get("examples"):
257
- section["examples"] = []
258
- if s_type == "important_notes" and not section.get("bulletPoints"):
259
- section["bulletPoints"] = []
260
- if s_type == "try_it_yourself" and not section.get("practiceProblems"):
261
- section["practiceProblems"] = []
262
- ordered[i] = section
263
-
264
- return {**lesson_data, "sections": ordered}
265
-
266
-
267
  @router.post("/lesson")
268
  async def rag_lesson(request: Request, payload: RagLessonRequest):
269
- # ── 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,20 +203,19 @@ async def rag_generate_problem(request: Request, payload: RagProblemRequest):
446
  top_k=5,
447
  )
448
  prompt = build_problem_generation_prompt(payload.topic, payload.difficulty, chunks)
449
- raw = await _generate_text(
450
- prompt,
451
- task_type="quiz_generation",
452
- max_new_tokens=600,
453
- enable_thinking=False,
454
- )
455
 
456
- parsed = _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,4 +267,4 @@ async def rag_analysis_context(request: Request, payload: RagAnalysisContextRequ
511
  chunks=chunks,
512
  )
513
 
514
- return {"curriculumContext": "\n".join(lines)}
 
2
 
3
  import json
4
  import logging
 
 
5
  from datetime import datetime, timezone
6
  from threading import Lock
7
  from typing import Any, Dict, List, Optional
 
9
  from fastapi import APIRouter, HTTPException, Request
10
  from pydantic import BaseModel, Field
11
 
12
+ from services.inference_client import InferenceRequest, create_default_client
 
 
 
 
 
13
  from rag.curriculum_rag import (
14
  build_analysis_curriculum_context,
15
  build_lesson_prompt,
16
  build_lesson_query,
17
  build_problem_generation_prompt,
 
18
  retrieve_curriculum_context,
 
19
  summarize_retrieval_confidence,
20
  )
21
+ from rag.vectorstore_loader import get_vectorstore_health
22
 
23
  try:
24
+ from firebase_admin import firestore as firebase_firestore # type: ignore[import-not-found]
25
  except Exception:
26
+ firebase_firestore = None # type: ignore[assignment]
27
 
28
  logger = logging.getLogger("mathpulse.rag")
29
  router = APIRouter(prefix="/api/rag", tags=["rag"])
 
41
  return _inference_client
42
 
43
 
44
+ async def _generate_text(prompt: str, task_type: str, max_new_tokens: int = 900) -> str:
 
 
 
 
 
45
  request = InferenceRequest(
46
  messages=[
47
  {"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
 
51
  max_new_tokens=max_new_tokens,
52
  temperature=0.2,
53
  top_p=0.9,
 
54
  )
55
  return _get_inference_client().generate_from_messages(request)
56
 
 
88
  logger.warning("rag_usage logging skipped: %s", exc)
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  class RagLessonRequest(BaseModel):
92
  topic: str
93
  subject: str
 
97
  moduleUnit: Optional[str] = None
98
  learnerLevel: Optional[str] = None
99
  userId: Optional[str] = None
 
 
 
 
100
 
101
 
102
  class RagProblemRequest(BaseModel):
 
115
 
116
  @router.get("/health")
117
  async def rag_health():
 
 
118
  try:
119
  health = get_vectorstore_health()
120
  return {
 
122
  "chunkCount": health["chunkCount"],
123
  "subjects": health["subjects"],
124
  "lastIngested": datetime.now(timezone.utc).isoformat(),
 
 
125
  }
126
  except Exception as exc:
127
  return {
 
129
  "chunkCount": 0,
130
  "subjects": {},
131
  "lastIngested": None,
 
 
132
  "warning": str(exc),
133
  }
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  @router.post("/lesson")
137
  async def rag_lesson(request: Request, payload: RagLessonRequest):
138
+ retrieval_query = build_lesson_query(
139
+ payload.topic,
140
+ payload.subject,
141
+ payload.quarter,
142
+ lesson_title=payload.lessonTitle,
143
+ competency=payload.learningCompetency,
144
+ module_unit=payload.moduleUnit,
145
+ learner_level=payload.learnerLevel,
146
+ )
147
+ chunks = retrieve_curriculum_context(
148
+ query=retrieval_query,
149
+ subject=payload.subject,
150
+ quarter=payload.quarter,
151
+ top_k=5,
152
+ )
153
+ prompt = build_lesson_prompt(
154
+ lesson_title=payload.lessonTitle or payload.topic,
155
+ competency=payload.learningCompetency or payload.topic,
156
+ grade_level="Grade 11-12",
157
+ subject=payload.subject,
158
+ quarter=payload.quarter,
159
+ learner_level=payload.learnerLevel,
160
+ module_unit=payload.moduleUnit,
161
+ curriculum_chunks=chunks,
162
+ )
163
+ explanation = await _generate_text(prompt, task_type="lesson_generation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  retrieval_summary = summarize_retrieval_confidence(chunks)
165
 
166
+ _log_rag_usage(
167
+ request,
168
+ event_type="lesson",
169
+ topic=retrieval_query,
170
+ subject=payload.subject,
171
+ quarter=payload.quarter,
172
+ chunks=chunks,
173
+ )
 
 
 
 
 
 
 
174
 
175
  return {
176
+ "explanation": explanation,
177
  "retrievalConfidence": retrieval_summary.get("confidence", 0.0),
178
  "retrievalBand": retrieval_summary.get("band", "low"),
179
+ "retrievalQuery": retrieval_query,
180
+ "needsReview": retrieval_summary.get("band", "low") == "low",
181
  "sources": [
182
  {
183
  "subject": row.get("subject"),
184
  "quarter": row.get("quarter"),
185
  "source_file": row.get("source_file"),
 
186
  "page": row.get("page"),
187
  "score": row.get("score"),
188
+ "content": row.get("content"),
189
  "content_domain": row.get("content_domain"),
190
  "chunk_type": row.get("chunk_type"),
 
191
  }
192
  for row in chunks
193
  ],
 
194
  }
195
 
196
 
 
203
  top_k=5,
204
  )
205
  prompt = build_problem_generation_prompt(payload.topic, payload.difficulty, chunks)
206
+ raw = await _generate_text(prompt, task_type="quiz_generation")
 
 
 
 
 
207
 
208
+ parsed: Dict[str, Any] = {}
209
+ cleaned = raw.strip()
210
+ if "{" in cleaned and "}" in cleaned:
211
+ try:
212
+ start = cleaned.find("{")
213
+ end = cleaned.rfind("}") + 1
214
+ parsed = json.loads(cleaned[start:end])
215
+ except Exception:
216
+ parsed = {}
217
 
218
  problem = str(parsed.get("problem") or raw)
 
 
 
 
219
  solution = str(parsed.get("solution") or "")
220
  competency_ref = str(parsed.get("competencyReference") or "DepEd competency-aligned")
221
 
 
267
  chunks=chunks,
268
  )
269
 
270
+ return {"curriculumContext": "\n".join(lines)}
routes/video_routes.py DELETED
@@ -1,102 +0,0 @@
1
- """
2
- Video lesson search routes for MathPulse AI.
3
- POST /api/lessons/videos/search — smart YouTube video search with RAG enrichment.
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import logging
9
- from typing import List, Optional
10
-
11
- from fastapi import APIRouter, HTTPException, Request
12
- from pydantic import BaseModel, Field
13
-
14
- from services.youtube_service import (
15
- get_video_search_results,
16
- YOUTUBE_API_KEY,
17
- )
18
-
19
- logger = logging.getLogger("mathpulse.videos")
20
- router = APIRouter(prefix="/api/lessons/videos", tags=["videos"])
21
-
22
-
23
- class VideoSearchRequest(BaseModel):
24
- topic: str = Field(..., min_length=1, max_length=200)
25
- grade_level: str = Field(default="Grade 11", max_length=50)
26
- subject: str = Field(default="General Mathematics", max_length=100)
27
- lesson_context: str = Field(default="", max_length=1000)
28
- lesson_id: Optional[str] = Field(default=None, max_length=100)
29
-
30
-
31
- class VideoResult(BaseModel):
32
- videoId: str
33
- title: str
34
- channelTitle: str
35
- thumbnailUrl: str
36
- durationSeconds: int
37
-
38
-
39
- class VideoSearchResponse(BaseModel):
40
- videos: List[VideoResult]
41
- cached: bool = False
42
-
43
-
44
- @router.post("/search", response_model=VideoSearchResponse)
45
- async def search_videos(request: Request, payload: VideoSearchRequest):
46
- """
47
- Search for relevant educational YouTube videos for a lesson topic.
48
-
49
- - Checks Firestore video_cache first (7-day TTL)
50
- - Enriches the search query with RAG curriculum keywords
51
- - Filters for educational channels, medium/long duration, HD quality
52
- - Returns up to 3 video results
53
- """
54
- # Graceful degradation: if YouTube API key is not configured, return 503
55
- # so the frontend can hide the video section silently
56
- if not YOUTUBE_API_KEY:
57
- logger.warning("YouTube API key not configured")
58
- raise HTTPException(
59
- status_code=503,
60
- detail={
61
- "error": "youtube_api_not_configured",
62
- "message": "YouTube API key is not configured on the server.",
63
- },
64
- )
65
-
66
- try:
67
- result = get_video_search_results(
68
- topic=payload.topic,
69
- subject=payload.subject,
70
- lesson_context=payload.lesson_context,
71
- grade_level=payload.grade_level,
72
- lesson_id=payload.lesson_id,
73
- max_results=3,
74
- )
75
-
76
- videos = [
77
- VideoResult(
78
- videoId=v["videoId"],
79
- title=v["title"],
80
- channelTitle=v["channelTitle"],
81
- thumbnailUrl=v["thumbnailUrl"],
82
- durationSeconds=v["durationSeconds"],
83
- )
84
- for v in result.get("videos", [])
85
- ]
86
-
87
- return VideoSearchResponse(
88
- videos=videos,
89
- cached=result.get("cached", False),
90
- )
91
-
92
- except HTTPException:
93
- raise
94
- except Exception as exc:
95
- logger.error("Video search endpoint error: %s", exc)
96
- raise HTTPException(
97
- status_code=500,
98
- detail={
99
- "error": "video_search_failed",
100
- "message": f"Failed to search videos: {exc}",
101
- },
102
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/download_vectorstore_from_firebase.py CHANGED
@@ -1,11 +1,10 @@
1
  """
2
  Download vectorstore directory from Firebase Storage at container startup.
3
- Run: python /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,66 +12,17 @@ from pathlib import Path
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,7 +35,6 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
85
  return False
86
 
87
  downloaded = 0
88
- skipped = 0
89
  errors = 0
90
 
91
  for blob in blobs:
@@ -97,10 +46,6 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
97
  local_path.parent.mkdir(parents=True, exist_ok=True)
98
 
99
  try:
100
- if local_path.exists() and blob.size is not None and local_path.stat().st_size == blob.size:
101
- logger.info("Skipped (already up-to-date): %s", blob.name)
102
- skipped += 1
103
- continue
104
  blob.download_to_filename(str(local_path))
105
  logger.info("Downloaded: %s (%d bytes)", blob.name, blob.size or 0)
106
  downloaded += 1
@@ -108,20 +53,10 @@ def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
108
  logger.error("Failed to download %s: %s", blob.name, e)
109
  errors += 1
110
 
111
- logger.info("Download complete: %d downloaded, %d 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")
 
1
  """
2
  Download vectorstore directory from Firebase Storage at container startup.
3
+ Run: python -m hf_space_test.scripts.download_vectorstore_from_firebase
4
  """
5
 
6
  from __future__ import annotations
7
 
 
8
  import logging
9
  import os
10
  import sys
 
12
 
13
  logger = logging.getLogger("mathpulse.download_vectorstore")
14
 
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
+ from backend.rag.firebase_storage_loader import _init_firebase_storage
 
 
 
 
 
 
18
 
19
+ REMOTE_PREFIX = "vectorstore/"
20
+ LOCAL_DEST_DIR = Path("/app/datasets/vectorstore")
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  def download_vectorstore(dest_dir: Path, prefix: str = REMOTE_PREFIX):
24
+ """Download all files under a prefix from Firebase Storage, preserving structure."""
25
+ _, bucket = _init_firebase_storage()
26
  if bucket is None:
27
  logger.warning("Firebase Storage not available, vectorstore download skipped")
28
  return False
 
35
  return False
36
 
37
  downloaded = 0
 
38
  errors = 0
39
 
40
  for blob in blobs:
 
46
  local_path.parent.mkdir(parents=True, exist_ok=True)
47
 
48
  try:
 
 
 
 
49
  blob.download_to_filename(str(local_path))
50
  logger.info("Downloaded: %s (%d bytes)", blob.name, blob.size or 0)
51
  downloaded += 1
 
53
  logger.error("Failed to download %s: %s", blob.name, e)
54
  errors += 1
55
 
56
+ logger.info("Download complete: %d files downloaded, %d errors", downloaded, errors)
57
  return errors == 0
58
 
59
 
60
  if __name__ == "__main__":
61
  logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
62
+ download_vectorstore(LOCAL_DEST_DIR, REMOTE_PREFIX)
 
 
 
 
 
 
 
 
 
 
scripts/ingest_curriculum.py CHANGED
@@ -1,159 +1,244 @@
1
  from __future__ import annotations
2
 
3
- import argparse
4
- import hashlib
5
  import json
6
- import logging
7
  import os
8
- import 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()
 
1
  from __future__ import annotations
2
 
 
 
3
  import json
 
4
  import os
5
+ import re
6
+ from collections import Counter
7
+ from datetime import datetime, timezone
8
  from pathlib import Path
9
+ from typing import Dict, List
10
 
11
+ import chromadb
12
+ import pdfplumber
13
+ from huggingface_hub import snapshot_download
14
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
15
+ from sentence_transformers import SentenceTransformer
16
+
17
+ BASE_DIR = Path(__file__).resolve().parents[1]
18
 
 
 
 
 
19
 
20
+ def _resolve_default_dir(local_path: Path, data_path: Path) -> Path:
21
+ return data_path if data_path.parent.exists() else local_path
22
+
23
+
24
+ CURRICULUM_DIR = Path(
25
+ os.getenv(
26
+ "CURRICULUM_DIR",
27
+ str(_resolve_default_dir(BASE_DIR / "datasets" / "curriculum", Path("/data/curriculum"))),
28
+ )
29
+ )
30
+ VECTORSTORE_DIR = Path(
31
+ os.getenv(
32
+ "VECTORSTORE_DIR",
33
+ str(_resolve_default_dir(BASE_DIR / "datasets" / "vectorstore", Path("/data/vectorstore"))),
34
+ )
35
+ )
36
+ COLLECTION_NAME = "curriculum_chunks"
37
+ EMBED_MODEL_NAME = "BAAI/bge-small-en-v1.5"
38
+ CURRICULUM_SOURCE_REPO_ID = os.getenv("CURRICULUM_SOURCE_REPO_ID", "").strip()
39
+ CURRICULUM_SOURCE_REPO_TYPE = os.getenv("CURRICULUM_SOURCE_REPO_TYPE", "dataset").strip() or "dataset"
40
+ CURRICULUM_SOURCE_REVISION = os.getenv("CURRICULUM_SOURCE_REVISION", "main").strip() or "main"
41
+
42
+ SUBJECT_MAP = {
43
+ "SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": "general_math",
44
+ "GENERAL-MATHEMATICS-1-2.pdf": "general_math",
45
+ "GENERAL-MATHEMATICS-1.pdf": "general_math",
46
+ "SDO_Navotas_Bus.Math_SHS_1stSem.FV-5.pdf": "business_math",
47
+ "SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf": "business_math",
48
+ "SDO_Navotas_STAT_PROB_SHS_1stSem.FV-3.pdf": "stat_prob",
49
+ "SDO_Navotas_STAT_PROB_SHS_1stSem.FV.pdf": "stat_prob",
50
+ "SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV-4.pdf": "org_management",
51
+ "SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf": "org_management",
52
+ }
53
+
54
+ QUARTER_HINTS = {
55
+ 1: ["q1", "quarter 1", "business", "finance", "arithmetic sequence", "geometric sequence", "series"],
56
+ 2: ["q2", "quarter 2", "measurement", "conversion", "functions", "piecewise", "statistics"],
57
+ 3: ["q3", "quarter 3", "trigonometry", "practical measurements", "random variable", "sampling"],
58
+ 4: ["q4", "quarter 4", "compound interest", "annuities", "loan", "hypothesis testing", "linear regression", "logic"],
59
+ }
60
+
61
+ DOMAIN_HINTS = {
62
+ "NA": ["number", "algebra", "sequence", "series", "interest", "annuity", "loan", "logic"],
63
+ "MG": ["measurement", "geometry", "trigonometry", "graph", "function", "piecewise"],
64
+ "DP": ["data", "probability", "statistics", "random variable", "sampling", "hypothesis", "regression"],
65
+ }
66
+
67
+ CHUNK_TYPE_HINTS = {
68
+ "learning_competency": ["learning competency", "code", "most essential learning", "melc", "competency"],
69
+ "example_problem": ["example", "solve", "problem", "exercise", "activity"],
70
+ "content_explanation": ["discussion", "content", "concept", "definition", "explain"],
71
+ }
72
+
73
+
74
+ def _norm(text: str) -> str:
75
+ return re.sub(r"\s+", " ", text.strip().lower())
76
+
77
+
78
+ def infer_quarter(text: str) -> int:
79
+ probe = _norm(text)
80
+ for quarter, hints in QUARTER_HINTS.items():
81
+ if any(h in probe for h in hints):
82
+ return quarter
83
+ return 1
84
+
85
+
86
+ def infer_domain(text: str) -> str:
87
+ probe = _norm(text)
88
+ scores: Dict[str, int] = {}
89
+ for domain, hints in DOMAIN_HINTS.items():
90
+ scores[domain] = sum(1 for hint in hints if hint in probe)
91
+ return max(scores, key=scores.get) if any(scores.values()) else "NA"
92
+
93
+
94
+ def infer_chunk_type(text: str) -> str:
95
+ probe = _norm(text)
96
+ scores: Dict[str, int] = {}
97
+ for chunk_type, hints in CHUNK_TYPE_HINTS.items():
98
+ scores[chunk_type] = sum(1 for hint in hints if hint in probe)
99
+ best = max(scores, key=scores.get)
100
+ return best if scores[best] > 0 else "content_explanation"
101
+
102
+
103
+ def extract_pdf_pages(pdf_path: Path) -> List[Dict[str, object]]:
104
+ rows: List[Dict[str, object]] = []
105
+ with pdfplumber.open(str(pdf_path)) as pdf:
106
+ for page_index, page in enumerate(pdf.pages, start=1):
107
+ page_text = page.extract_text() or ""
108
+ table_lines: List[str] = []
109
+ for table in page.extract_tables() or []:
110
+ for row in table:
111
+ cells = [str(cell).strip() for cell in (row or []) if str(cell or "").strip()]
112
+ if cells:
113
+ table_lines.append(" | ".join(cells))
114
+ combined = "\n".join([page_text, "\n".join(table_lines)]).strip()
115
+ if combined:
116
+ rows.append({"page": page_index, "text": combined})
117
+ return rows
118
+
119
+
120
+ def chunk_text(page_text: str) -> List[str]:
121
+ splitter = RecursiveCharacterTextSplitter(
122
+ chunk_size=2000,
123
+ chunk_overlap=200,
124
+ separators=["\n\n", "\n", ". ", " ", ""],
125
+ )
126
+ return [chunk.strip() for chunk in splitter.split_text(page_text) if chunk.strip()]
127
+
128
+
129
+ def _ensure_curriculum_pdfs() -> List[Path]:
130
+ pdf_files = sorted(CURRICULUM_DIR.glob("*.pdf"))
131
+ if pdf_files:
132
+ return pdf_files
133
+
134
+ if not CURRICULUM_SOURCE_REPO_ID:
135
+ raise SystemExit(
136
+ "No PDF files found in datasets/curriculum/ and CURRICULUM_SOURCE_REPO_ID is not set. "
137
+ "Upload the PDFs to a Hugging Face repo and point CURRICULUM_SOURCE_REPO_ID at it."
138
+ )
139
+
140
+ snapshot_dir = Path(
141
+ snapshot_download(
142
+ repo_id=CURRICULUM_SOURCE_REPO_ID,
143
+ repo_type=CURRICULUM_SOURCE_REPO_TYPE,
144
+ revision=CURRICULUM_SOURCE_REVISION,
145
+ allow_patterns=["*.pdf", "**/*.pdf"],
146
+ )
147
+ )
148
+
149
+ source_pdfs = sorted(snapshot_dir.rglob("*.pdf"))
150
+ if not source_pdfs:
151
+ raise SystemExit(
152
+ f"No PDF files found in Hugging Face repo {CURRICULUM_SOURCE_REPO_TYPE}:{CURRICULUM_SOURCE_REPO_ID}@{CURRICULUM_SOURCE_REVISION}."
153
+ )
154
+
155
+ CURRICULUM_DIR.mkdir(parents=True, exist_ok=True)
156
+ for source_pdf in source_pdfs:
157
+ target_pdf = CURRICULUM_DIR / source_pdf.name
158
+ target_pdf.write_bytes(source_pdf.read_bytes())
159
+
160
+ return sorted(CURRICULUM_DIR.glob("*.pdf"))
161
 
162
 
163
  def main() -> None:
164
+ if not CURRICULUM_DIR.exists():
165
+ raise SystemExit(f"Missing curriculum directory: {CURRICULUM_DIR}")
166
+
167
+ pdf_files = _ensure_curriculum_pdfs()
168
+ if not pdf_files:
169
+ raise SystemExit("No PDF files found in datasets/curriculum/")
170
+
171
+ VECTORSTORE_DIR.mkdir(parents=True, exist_ok=True)
172
+
173
+ documents: List[str] = []
174
+ metadatas: List[Dict[str, object]] = []
175
+ ids: List[str] = []
176
+
177
+ per_subject = Counter()
178
+ per_quarter = Counter()
179
+ per_domain = Counter()
180
+
181
+ for pdf_file in pdf_files:
182
+ subject = SUBJECT_MAP.get(pdf_file.name, "general_math")
183
+ page_rows = extract_pdf_pages(pdf_file)
184
+ for page_row in page_rows:
185
+ page_number = int(page_row["page"])
186
+ text = str(page_row["text"])
187
+ for idx, chunk in enumerate(chunk_text(text), start=1):
188
+ quarter = infer_quarter(chunk)
189
+ domain = infer_domain(chunk)
190
+ chunk_type = infer_chunk_type(chunk)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  metadata = {
193
  "subject": subject,
194
  "quarter": quarter,
195
+ "content_domain": domain,
196
  "chunk_type": chunk_type,
197
+ "source_file": pdf_file.name,
198
+ "page": page_number,
199
  }
200
+ chunk_id = f"{pdf_file.stem}-{page_number}-{idx}"
201
 
202
+ documents.append(chunk)
203
  metadatas.append(metadata)
204
  ids.append(chunk_id)
205
+
206
+ per_subject[subject] += 1
207
+ per_quarter[str(quarter)] += 1
208
+ per_domain[domain] += 1
209
+
210
+ embedder = SentenceTransformer(EMBED_MODEL_NAME)
211
+ embeddings = embedder.encode(documents, show_progress_bar=True).tolist()
212
+
213
+ client = chromadb.PersistentClient(path=str(VECTORSTORE_DIR))
214
+ existing = [c.name for c in client.list_collections()]
215
+ if COLLECTION_NAME in existing:
216
+ client.delete_collection(COLLECTION_NAME)
217
+ collection = client.create_collection(name=COLLECTION_NAME)
218
+ collection.add(ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings)
219
+
220
+ summary = {
221
+ "lastIngested": datetime.now(timezone.utc).isoformat(),
222
+ "totalChunks": len(documents),
223
+ "chunksPerSubject": dict(per_subject),
224
+ "chunksPerQuarter": dict(per_quarter),
225
+ "chunksPerDomain": dict(per_domain),
226
+ "sourceFiles": [pdf.name for pdf in pdf_files],
227
+ }
228
+ (VECTORSTORE_DIR / "ingest_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
229
+
230
+ print("=== Curriculum Ingestion Summary ===")
231
+ print(f"Total chunks: {summary['totalChunks']}")
232
+ print("Chunks per subject:")
233
+ for subject, count in sorted(per_subject.items()):
234
+ print(f" - {subject}: {count}")
235
+ print("Chunks per quarter:")
236
+ for quarter, count in sorted(per_quarter.items()):
237
+ print(f" - Q{quarter}: {count}")
238
+ print("Chunks per domain:")
239
+ for domain, count in sorted(per_domain.items()):
240
+ print(f" - {domain}: {count}")
241
 
242
 
243
  if __name__ == "__main__":
244
+ main()
 
scripts/ingest_from_storage.py DELETED
@@ -1,285 +0,0 @@
1
- """
2
- Ingest curriculum PDFs from Firebase Storage into ChromaDB.
3
- Run: python -m backend.scripts.ingest_from_storage
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import logging
9
- import os
10
- import sys
11
- from pathlib import Path
12
- from typing import Any, Dict, List, Optional
13
-
14
- logger = logging.getLogger("mathpulse.ingest")
15
-
16
- sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
17
-
18
- from rag.firebase_storage_loader import (
19
- PDF_METADATA,
20
- download_pdf_from_storage,
21
- list_curriculum_blobs,
22
- )
23
-
24
- _CONTENT_DOMAIN_CLASSIFIERS = [
25
- ("introduction", ["introduction", "welcome", "overview", "objectives", "learning objectives"]),
26
- ("key_concepts", ["key concepts", "key ideas", "main concepts", "definitions", "key terms"]),
27
- ("worked_examples", ["example", "worked example", "illustrative example", "sample problem", "solution"]),
28
- ("important_notes", ["important", "note", "remember", "tip", "caution", "warning", "key point"]),
29
- ("practice", ["practice", "exercise", "try it", "your turn", "activity", "problem set"]),
30
- ("summary", ["summary", "recap", "key takeaways", "wrap-up", "conclusion"]),
31
- ("assessment", ["assessment", "quiz", "test", "evaluation", "exam"]),
32
- ]
33
-
34
- _CONTENT_TYPE_CLASSIFIERS = [
35
- ("definition", ["definition", "define", "means", "is defined as"]),
36
- ("formula", ["formula", "equation", "expression", "rule"]),
37
- ("procedure", ["step", "method", "how to", "procedure", "process"]),
38
- ("concept", ["concept", "idea", "principle", "theory"]),
39
- ("application", ["application", "use", "example", "solve", "problem"]),
40
- ]
41
-
42
-
43
- def _classify_chunk(content: str) -> tuple[str, str]:
44
- content_lower = content.lower()
45
- content_domain = "general"
46
- chunk_type = "concept"
47
-
48
- for domain, keywords in _CONTENT_DOMAIN_CLASSIFIERS:
49
- if any(kw in content_lower for kw in keywords):
50
- content_domain = domain
51
- break
52
-
53
- for ctype, keywords in _CONTENT_TYPE_CLASSIFIERS:
54
- if any(kw in content_lower for kw in keywords):
55
- chunk_type = ctype
56
- break
57
-
58
- return content_domain, chunk_type
59
-
60
-
61
- def _classify_lesson_section(content: str) -> str:
62
- content_lower = content.lower().strip()
63
- first_sentence = content_lower[:200]
64
-
65
- for domain, keywords in _CONTENT_DOMAIN_CLASSIFIERS:
66
- if any(kw in first_sentence for kw in keywords):
67
- return domain
68
- return "general"
69
-
70
-
71
- def chunk_text_preserve_pages(text: str, page_starts: List[int], chunk_size: int = 500, overlap: int = 80) -> List[Dict[str, Any]]:
72
- """Split text into overlapping chunks, preserving page traceability."""
73
- # Filter out None/empty entries that can result from malformed PDF text extraction
74
- words = [w for w in text.split() if w is not None and str(w).strip()]
75
- chunks = []
76
- i = 0
77
- chunk_idx = 0
78
- while i < len(words):
79
- chunk_words = words[i : i + chunk_size]
80
- chunk_text = " ".join(str(w) for w in chunk_words)
81
- estimated_page = max(1, (i // chunk_size) + 1)
82
- content_domain, chunk_type = _classify_chunk(chunk_text)
83
-
84
- chunks.append({
85
- "text": chunk_text,
86
- "chunk_index": chunk_idx,
87
- "estimated_page": estimated_page,
88
- "content_domain": content_domain,
89
- "chunk_type": chunk_type,
90
- })
91
- i += chunk_size - overlap
92
- chunk_idx += 1
93
- return chunks
94
-
95
-
96
- def extract_pdf_text_and_pages(pdf_bytes: bytes) -> tuple[str, List[int]]:
97
- """Extract text from PDF bytes, returning full text and page start positions."""
98
- try:
99
- from pypdf import PdfReader
100
- except ImportError:
101
- try:
102
- import PyPDF2 as PdfReaderModule
103
- from PyPDF2 import PdfReader
104
- except ImportError:
105
- logger.error("No PDF library available. Install: pip install pypdf")
106
- return "", []
107
-
108
- import io
109
- reader = PdfReader(io.BytesIO(pdf_bytes))
110
- pages: List[str] = []
111
- for page in reader.pages:
112
- text = page.extract_text() or ""
113
- pages.append(text)
114
-
115
- page_starts = []
116
- position = 0
117
- for page_text in pages:
118
- page_starts.append(position)
119
- position += len(page_text) + 1
120
-
121
- full_text = "\n".join(pages)
122
- return full_text, page_starts
123
-
124
-
125
- def get_firestore_client():
126
- try:
127
- import firebase_admin
128
- from firebase_admin import firestore
129
- if not firebase_admin._apps:
130
- sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
131
- sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
132
- bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
133
- if sa_json:
134
- import json as _json
135
- from firebase_admin import credentials
136
- creds = credentials.Certificate(_json.loads(sa_json))
137
- firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
138
- elif sa_file and Path(sa_file).exists():
139
- from firebase_admin import credentials
140
- creds = credentials.Certificate(sa_file)
141
- firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
142
- else:
143
- firebase_admin.initialize_app(options={"storageBucket": bucket_name})
144
- return firestore.client()
145
- except Exception as e:
146
- logger.warning("Firestore unavailable: %s", e)
147
- return None
148
-
149
-
150
- def ingest_from_firebase_storage(force_reindex: bool = False):
151
- """Download PDFs from Firebase Storage and ingest into ChromaDB."""
152
- try:
153
- from sentence_transformers import SentenceTransformer
154
- import chromadb
155
- except ImportError:
156
- logger.error("Missing dependencies. Install: pip install chromadb sentence-transformers pypdf")
157
- return
158
-
159
- chroma_path = os.getenv("CURRICULUM_VECTORSTORE_DIR", "datasets/vectorstore")
160
- chroma_client = chromadb.PersistentClient(path=chroma_path)
161
- collection = chroma_client.get_or_create_collection(
162
- name="curriculum_chunks",
163
- metadata={"hnsw:space": "cosine"},
164
- )
165
- embedder = SentenceTransformer("BAAI/bge-base-en-v1.5")
166
-
167
- db = get_firestore_client()
168
-
169
- logger.info("Starting ingestion from Firebase Storage...")
170
- ingested_count = 0
171
- skipped_count = 0
172
- error_count = 0
173
-
174
- for storage_path, metadata in PDF_METADATA.items():
175
- doc_id = storage_path.replace("/", "_").replace(".pdf", "")
176
-
177
- if db:
178
- try:
179
- doc_ref = db.collection("curriculumDocuments").document(doc_id)
180
- existing = doc_ref.get()
181
- if existing.exists:
182
- if not force_reindex and existing.to_dict().get("status") == "ingested":
183
- logger.info("[SKIP] %s already ingested", storage_path)
184
- skipped_count += 1
185
- continue
186
- except Exception as e:
187
- logger.warning("Firestore check failed for %s: %s", storage_path, e)
188
-
189
- logger.info("Downloading: %s", storage_path)
190
- pdf_bytes = download_pdf_from_storage(storage_path)
191
- if pdf_bytes is None:
192
- logger.error("[ERROR] Failed to download: %s", storage_path)
193
- if db:
194
- try:
195
- doc_ref.set({
196
- "storagePath": storage_path,
197
- "status": "failed",
198
- "error": "download_failed",
199
- **metadata,
200
- }, merge=True)
201
- except:
202
- pass
203
- error_count += 1
204
- continue
205
-
206
- logger.info("Extracting text from: %s (%d bytes)", storage_path, len(pdf_bytes))
207
- full_text, page_starts = extract_pdf_text_and_pages(pdf_bytes)
208
- if not full_text.strip():
209
- logger.warning("[WARN] No text extracted from: %s", storage_path)
210
- error_count += 1
211
- continue
212
-
213
- chunks = chunk_text_preserve_pages(full_text, page_starts)
214
- logger.info(" -> %d chunks created", len(chunks))
215
-
216
- existing_ids = [cid for cid in collection.get()["ids"] if cid.startswith(f"{doc_id}_chunk_")]
217
- if existing_ids:
218
- collection.delete(ids=existing_ids)
219
- logger.info(" Removed %d existing chunks", len(existing_ids))
220
-
221
- for chunk in chunks:
222
- chunk_text = chunk.get("text", "")
223
- if not isinstance(chunk_text, str) or not chunk_text.strip():
224
- logger.warning(" Skipping empty/invalid chunk %s (type=%s, len=%d)", chunk.get("chunk_index"), type(chunk_text), len(chunk_text))
225
- continue
226
- chunk_id = f"{doc_id}_chunk_{chunk['chunk_index']}"
227
- try:
228
- embedding = embedder.encode(chunk_text, normalize_embeddings=True).tolist()
229
- except Exception as enc_err:
230
- logger.warning(" Skipping unencodable chunk %s: %s", chunk.get("chunk_index"), enc_err)
231
- continue
232
-
233
- collection.add(
234
- embeddings=[embedding],
235
- documents=[chunk_text],
236
- metadatas=[{
237
- "document_id": doc_id,
238
- "module_id": metadata.get("subjectId", ""),
239
- "lesson_id": f"lesson-{doc_id}",
240
- "title": metadata.get("subject", ""),
241
- "subject": metadata.get("subject", ""),
242
- "subjectId": metadata.get("subjectId", ""),
243
- "quarter": metadata.get("quarter", 1),
244
- "competency_code": metadata.get("competency_code", ""),
245
- "content_domain": chunk["content_domain"],
246
- "chunk_type": chunk["chunk_type"],
247
- "source_file": storage_path.split("/")[-1],
248
- "storage_path": storage_path,
249
- "page": chunk["estimated_page"],
250
- "chunk_index": chunk["chunk_index"],
251
- "type": metadata.get("type", ""),
252
- }],
253
- ids=[chunk_id],
254
- )
255
-
256
- if db:
257
- try:
258
- doc_ref.set({
259
- "id": doc_id,
260
- "storagePath": storage_path,
261
- "status": "ingested",
262
- "ingestedAt": __import__("firebase_admin").firestore.SERVER_TIMESTAMP,
263
- "chunkCount": len(chunks),
264
- **metadata,
265
- }, merge=True)
266
- except Exception as e:
267
- logger.warning("Firestore update failed: %s", e)
268
-
269
- logger.info("[OK] Ingested %s (%d chunks)", storage_path, len(chunks))
270
- ingested_count += 1
271
-
272
- logger.info("=" * 50)
273
- logger.info("Ingestion complete: %d ingested, %d skipped, %d errors", ingested_count, skipped_count, error_count)
274
- logger.info("Total chunks in ChromaDB: %d", collection.count())
275
-
276
-
277
- if __name__ == "__main__":
278
- import argparse
279
- logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
280
-
281
- parser = argparse.ArgumentParser(description="Ingest curriculum PDFs from Firebase Storage into ChromaDB")
282
- parser.add_argument("--force", action="store_true", help="Re-ingest even if already ingested")
283
- args = parser.parse_args()
284
-
285
- ingest_from_firebase_storage(force_reindex=args.force)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/migrate_grade12_to_grade11.py DELETED
@@ -1,107 +0,0 @@
1
- """
2
- Migrate Grade 12 users to Grade 11.
3
-
4
- Run this to convert all existing Grade 12 users to Grade 11:
5
- python backend/scripts/migrate_grade12_to_grade11.py
6
-
7
- This handles:
8
- - Firestore user profiles
9
- - Progress records
10
- - Any references to Grade 12
11
- """
12
-
13
- import logging
14
- import os
15
- import sys
16
- from pathlib import Path
17
-
18
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19
-
20
- logger = logging.getLogger(__name__)
21
-
22
-
23
- def migrate_grade_12_to_grade_11():
24
- """Migrate all Grade 12 users to Grade 11."""
25
- try:
26
- import firebase_admin
27
- from firebase_admin import firestore
28
-
29
- svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
30
- if svc_account:
31
- import json
32
- from firebase_admin import credentials
33
- creds = credentials.Certificate(json.loads(svc_account))
34
- firebase_admin.initialize_app(creds)
35
- else:
36
- firebase_admin.initialize_app()
37
-
38
- db = firestore.client()
39
- print("Firebase initialized")
40
-
41
- except Exception as e:
42
- print(f"Failed to initialize Firebase: {e}")
43
- return
44
-
45
- # Count migrations
46
- users_migrated = 0
47
- progress_migrated = 0
48
-
49
- # Migrate users collection
50
- print("\n--- Migrating users ---")
51
- users_ref = db.collection("users")
52
-
53
- # Batch update for users
54
- batch = db.batch()
55
- user_count = 0
56
-
57
- for doc in users_ref.stream():
58
- data = doc.to_dict()
59
- if data.get("grade") == "Grade 12":
60
- batch.update(doc.reference, {"grade": "Grade 11"})
61
- user_count += 1
62
- print(f" Migrating user: {doc.id} ({data.get('name', 'Unknown')})")
63
-
64
- if user_count >= 500:
65
- batch.commit()
66
- users_migrated += user_count
67
- user_count = 0
68
- batch = db.batch()
69
-
70
- if user_count > 0:
71
- batch.commit()
72
- users_migrated += user_count
73
-
74
- print(f" => Migrated {users_migrated} users to Grade 11")
75
-
76
- # Migrate progress collection
77
- print("\n--- Migrating progress ---")
78
- progress_ref = db.collection("progress")
79
- batch = db.batch()
80
- progress_count = 0
81
-
82
- for doc in progress_ref.stream():
83
- data = doc.to_dict()
84
- if data.get("gradeLevel") == "Grade 12":
85
- batch.update(doc.reference, {"gradeLevel": "Grade 11"})
86
- progress_count += 1
87
-
88
- if progress_count >= 500:
89
- batch.commit()
90
- progress_migrated += progress_count
91
- progress_count = 0
92
- batch = db.batch()
93
-
94
- if progress_count > 0:
95
- batch.commit()
96
- progress_migrated += progress_count
97
-
98
- print(f" => Migrated {progress_migrated} progress records to Grade 11")
99
-
100
- print(f"\n=== Migration complete ===")
101
- print(f"Users migrated: {users_migrated}")
102
- print(f"Progress migrated: {progress_migrated}")
103
-
104
-
105
- if __name__ == "__main__":
106
- logging.basicConfig(level=logging.INFO)
107
- migrate_grade_12_to_grade_11()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/register_firestore_metadata.py DELETED
@@ -1,183 +0,0 @@
1
- """
2
- Register curriculum document metadata in Firestore.
3
- Populates the curriculumDocuments collection so the app can display
4
- lessons mapped to their source PDFs before ingestion.
5
-
6
- Run: python backend/scripts/register_firestore_metadata.py
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import os
12
- import sys
13
- from pathlib import Path
14
-
15
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16
-
17
-
18
- def _get_firestore_client():
19
- try:
20
- import firebase_admin
21
- from firebase_admin import firestore
22
- if not firebase_admin._apps:
23
- sa_json = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
24
- sa_file = os.getenv("FIREBASE_SERVICE_ACCOUNT_FILE")
25
- bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "mathpulse-ai-2026.firebasestorage.app")
26
- if sa_json:
27
- import json as _json
28
- from firebase_admin import credentials
29
- creds = credentials.Certificate(_json.loads(sa_json))
30
- firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
31
- elif sa_file and Path(sa_file).exists():
32
- from firebase_admin import credentials
33
- creds = credentials.Certificate(sa_file)
34
- firebase_admin.initialize_app(creds, {"storageBucket": bucket_name})
35
- else:
36
- firebase_admin.initialize_app(options={"storageBucket": bucket_name})
37
- return firestore.client()
38
- except Exception as e:
39
- print(f"Firestore init failed: {e}")
40
- return None
41
-
42
-
43
- CURRICULUM_DOCUMENTS = [
44
- {
45
- "id": "gm_lesson_1",
46
- "moduleId": "gm-q1-business-finance",
47
- "lessonId": "gm-q1-bf-1",
48
- "title": "Represent business transactions and financial goals using variables and equations.",
49
- "subject": "General Mathematics",
50
- "subjectId": "gen-math",
51
- "quarter": 1,
52
- "competencyCode": "GM11-BF-1",
53
- "learningCompetency": "Represent business transactions and financial goals using variables and equations.",
54
- "storagePath": "curriculum/general_math/GENERAL-MATHEMATICS-1.pdf",
55
- "status": "uploaded",
56
- },
57
- {
58
- "id": "gm_navotas_lesson_1",
59
- "moduleId": "gm-q1-patterns-sequences-series",
60
- "lessonId": "gm-q1-pss-1",
61
- "title": "Identify and describe arithmetic and geometric patterns in data.",
62
- "subject": "General Mathematics",
63
- "subjectId": "gen-math",
64
- "quarter": 1,
65
- "competencyCode": "GM11-PSS-1",
66
- "learningCompetency": "Identify and describe arithmetic and geometric patterns in data.",
67
- "storagePath": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
68
- "status": "uploaded",
69
- },
70
- {
71
- "id": "bm_lesson_1",
72
- "moduleId": "bm-q1-business-math",
73
- "lessonId": "bm-q1-1",
74
- "title": "Translate verbal phrases to mathematical expressions; model business scenarios using linear equations and inequalities.",
75
- "subject": "Business Mathematics",
76
- "subjectId": "business-math",
77
- "quarter": 1,
78
- "competencyCode": "ABM_BM11BS-Ia-b-1",
79
- "learningCompetency": "Translate verbal phrases to mathematical expressions; model business scenarios using linear equations and inequalities.",
80
- "storagePath": "curriculum/business_math/SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf",
81
- "status": "uploaded",
82
- },
83
- {
84
- "id": "stat_lesson_1",
85
- "moduleId": "stat-q1-probability",
86
- "lessonId": "stat-q1-1",
87
- "title": "Define and describe random variables and their types.",
88
- "subject": "Statistics and Probability",
89
- "subjectId": "stats-prob",
90
- "quarter": 1,
91
- "competencyCode": "SP_SHS11-Ia-1",
92
- "learningCompetency": "Define and describe random variables and their types.",
93
- "storagePath": "curriculum/stat_prob/SDO_Navotas_STAT_PROB_SHS_1stSem.FV.pdf",
94
- "status": "uploaded",
95
- },
96
- {
97
- "id": "fm1_lesson_1",
98
- "moduleId": "fm1-q1-counting",
99
- "lessonId": "fm1-q1-fpc-1",
100
- "title": "Apply the fundamental counting principle in contextual problems.",
101
- "subject": "Finite Mathematics 1",
102
- "subjectId": "finite-math-1",
103
- "quarter": 1,
104
- "competencyCode": "FM1-SHS11-Ia-1",
105
- "learningCompetency": "Apply the fundamental counting principle in contextual problems.",
106
- "storagePath": "curriculum/finite_math/Finite-Mathematics-1-1.pdf",
107
- "status": "uploaded",
108
- },
109
- {
110
- "id": "fm2_lesson_1",
111
- "moduleId": "fm2-q1-matrices",
112
- "lessonId": "fm2-q1-matrices-1",
113
- "title": "Represent contextual data using matrix notation.",
114
- "subject": "Finite Mathematics 2",
115
- "subjectId": "finite-math-2",
116
- "quarter": 1,
117
- "competencyCode": "FM2-SHS11-Ia-1",
118
- "learningCompetency": "Represent contextual data using matrix notation.",
119
- "storagePath": "curriculum/finite_math/Finite-Mathematics-2-1.pdf",
120
- "status": "uploaded",
121
- },
122
- {
123
- "id": "org_mgmt_lesson_1",
124
- "moduleId": "org-mgmt-q1",
125
- "lessonId": "org-mgmt-q1-1",
126
- "title": "Understand the fundamental concepts of organization and management.",
127
- "subject": "Organization and Management",
128
- "subjectId": "org-mgmt",
129
- "quarter": 1,
130
- "competencyCode": "ABM_OM11-Ia-1",
131
- "learningCompetency": "Understand the fundamental concepts of organization and management.",
132
- "storagePath": "curriculum/org_mgmt/SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf",
133
- "status": "uploaded",
134
- },
135
- ]
136
-
137
-
138
- def register_metadata(force: bool = False):
139
- db = _get_firestore_client()
140
- if db is None:
141
- print("ERROR: Cannot connect to Firestore. Check credentials.")
142
- print("Set FIREBASE_SERVICE_ACCOUNT_JSON or place mathpulse-sa.json in backend/ directory.")
143
- return
144
-
145
- print("Connected to Firestore.")
146
- print("-" * 50)
147
-
148
- registered = 0
149
- skipped = 0
150
- updated = 0
151
-
152
- for doc in CURRICULUM_DOCUMENTS:
153
- doc_id = doc["id"]
154
- doc_ref = db.collection("curriculumDocuments").document(doc_id)
155
- existing = doc_ref.get()
156
-
157
- if existing.exists and not force:
158
- print(f"[SKIP] {doc_id} already registered")
159
- skipped += 1
160
- continue
161
-
162
- if existing.exists and force:
163
- updated += 1
164
- else:
165
- registered += 1
166
-
167
- data = {
168
- **doc,
169
- "uploadedAt": None,
170
- }
171
- doc_ref.set(data, merge=True)
172
- print(f"[OK] {'Updated' if force and existing.exists else 'Registered'} {doc_id} -> {doc.get('storagePath')}")
173
-
174
- print("-" * 50)
175
- print(f"Done: {registered} registered, {skipped} skipped, {updated} updated.")
176
-
177
-
178
- if __name__ == "__main__":
179
- import argparse
180
- parser = argparse.ArgumentParser(description="Register curriculum document metadata in Firestore")
181
- parser.add_argument("--force", action="store_true", help="Overwrite existing records")
182
- args = parser.parse_args()
183
- register_metadata(force=args.force)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/seed_curriculum.py DELETED
@@ -1,64 +0,0 @@
1
- """
2
- Seed Firestore curriculum collection from static data.
3
-
4
- Run this ONCE to migrate static curriculum to Firestore:
5
- python backend/scripts/seed_curriculum.py
6
-
7
- After seeding, the curriculum API will read from Firestore.
8
- """
9
-
10
- import logging
11
- import json
12
- import os
13
- import sys
14
- from pathlib import Path
15
-
16
- # Add backend to path
17
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18
-
19
- from services.curriculum_service import _STATIC_SUBJECTS
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
-
24
- def seed_curriculum():
25
- """Seed curriculum subjects to Firestore."""
26
- try:
27
- import firebase_admin
28
- from firebase_admin import firestore, credentials
29
-
30
- # Initialize Firebase
31
- svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
32
- if svc_account:
33
- sa_creds = credentials.Certificate(json.loads(svc_account))
34
- firebase_admin.initialize_app(sa_creds)
35
- else:
36
- firebase_admin.initialize_app()
37
-
38
- db = firestore.client()
39
- print("Firebase initialized")
40
-
41
- except Exception as e:
42
- print(f"Failed to initialize Firebase: {e}")
43
- return
44
-
45
- # Seed new subjects
46
- subjects_ref = db.collection("subjects")
47
- count = 0
48
-
49
- for subject in _STATIC_SUBJECTS:
50
- doc_ref = subjects_ref.document(subject["id"])
51
- doc_ref.set(subject)
52
- count += 1
53
- print(f" Seeded: {subject['id']} - {subject['name']} ({len(subject.get('topics', []))} topics)")
54
-
55
- print(f"\nSeeded {count} subjects to Firestore")
56
- print("\nCurriculum is now available at:")
57
- print(" GET /api/curriculum/subjects")
58
- print(" GET /api/curriculum/subjects/{id}")
59
- print(" GET /api/curriculum/subjects/{id}/topics")
60
-
61
-
62
- if __name__ == "__main__":
63
- logging.basicConfig(level=logging.INFO)
64
- seed_curriculum()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/upload_curriculum_pdfs.py DELETED
@@ -1,264 +0,0 @@
1
- """
2
- Upload DepEd curriculum PDFs to Firebase Storage.
3
- Run once during initial setup: python scripts/upload_curriculum_pdfs.py
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import os
9
- import sys
10
- from pathlib import Path
11
- from typing import Dict, List
12
-
13
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
14
-
15
- LOCAL_PDF_DIR = r"C:\Users\Deign\Downloads\Documents"
16
-
17
- PDF_METADATA: Dict[str, Dict[str, object]] = {
18
- "GENERAL-MATHEMATICS-1.pdf": {
19
- "subject": "General Mathematics",
20
- "type": "curriculum_guide",
21
- "strand": ["STEM", "ABM", "HUMSS", "GAS", "TVL"],
22
- "quarters": ["Q1", "Q2", "Q3", "Q4"],
23
- "storage_path": "curriculum/general_math/GENERAL-MATHEMATICS-1.pdf",
24
- },
25
- "Finite-Mathematics-1-1.pdf": {
26
- "subject": "Finite Mathematics 1",
27
- "type": "curriculum_guide",
28
- "strand": ["STEM", "ABM"],
29
- "quarters": ["Q1", "Q2"],
30
- "storage_path": "curriculum/finite_math/Finite-Mathematics-1-1.pdf",
31
- },
32
- "Finite-Mathematics-2-1.pdf": {
33
- "subject": "Finite Mathematics 2",
34
- "type": "curriculum_guide",
35
- "strand": ["STEM", "ABM"],
36
- "quarters": ["Q1", "Q2"],
37
- "storage_path": "curriculum/finite_math/Finite-Mathematics-2-1.pdf",
38
- },
39
- "SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": {
40
- "subject": "General Mathematics",
41
- "type": "sdo_module",
42
- "strand": ["STEM", "ABM", "HUMSS", "GAS", "TVL"],
43
- "quarters": ["Q1", "Q2"],
44
- "storage_path": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
45
- },
46
- "SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf": {
47
- "subject": "Business Mathematics",
48
- "type": "sdo_module",
49
- "strand": ["ABM"],
50
- "quarters": ["Q1", "Q2"],
51
- "storage_path": "curriculum/business_math/SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf",
52
- },
53
- "SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf": {
54
- "subject": "Organization and Management",
55
- "type": "sdo_module",
56
- "strand": ["ABM"],
57
- "quarters": ["Q1", "Q2"],
58
- "storage_path": "curriculum/org_mgmt/SDO_Navotas_SHS_ABM_OrgAndMngt_FirstSem_FV.pdf",
59
- },
60
- "SDO_Navotas_STAT_PROB_SHS_1stSem_FV.pdf": {
61
- "subject": "Statistics and Probability",
62
- "type": "sdo_module",
63
- "strand": ["STEM", "ABM"],
64
- "quarters": ["Q1", "Q2"],
65
- "storage_path": "curriculum/stat_prob/SDO_Navotas_STAT_PROB_SHS_1stSem_FV.pdf",
66
- },
67
- }
68
-
69
-
70
- def chunk_text(text: str, chunk_size: int = 600, overlap: int = 100) -> List[str]:
71
- """Split text into overlapping chunks."""
72
- words = text.split()
73
- chunks: List[str] = []
74
- i = 0
75
- while i < len(words):
76
- chunk = " ".join(words[i : i + chunk_size])
77
- chunks.append(chunk)
78
- i += chunk_size - overlap
79
- return chunks
80
-
81
-
82
- def upload_pdfs():
83
- """Upload PDFs from local directory to Firebase Storage."""
84
- try:
85
- import firebase_admin
86
- from firebase_admin import credentials, storage, firestore
87
- except ImportError:
88
- print("ERROR: firebase-admin not installed. Run: pip install firebase-admin")
89
- return
90
-
91
- service_account_path = Path(__file__).resolve().parents[1] / "serviceAccountKey.json"
92
- if not service_account_path.exists():
93
- print(f"ERROR: Service account key not found at {service_account_path}")
94
- return
95
-
96
- bucket_name = os.getenv("FIREBASE_STORAGE_BUCKET", "").strip()
97
- if not bucket_name:
98
- print("ERROR: FIREBASE_STORAGE_BUCKET not set in environment")
99
- return
100
-
101
- cred = credentials.Certificate(str(service_account_path))
102
- firebase_admin.initialize_app(cred, {"storageBucket": bucket_name})
103
-
104
- bucket = storage.bucket()
105
- db = firestore.client()
106
-
107
- print(f"Scanning: {LOCAL_PDF_DIR}")
108
- print("-" * 50)
109
-
110
- uploaded = 0
111
- skipped = 0
112
-
113
- for filename, meta in PDF_METADATA.items():
114
- local_path = Path(LOCAL_PDF_DIR) / filename
115
-
116
- if not local_path.exists():
117
- print(f"[SKIP] {filename} not found in {LOCAL_PDF_DIR}")
118
- skipped += 1
119
- continue
120
-
121
- doc_ref = db.collection("curriculumDocs").document(filename)
122
- if doc_ref.get().exists:
123
- print(f"[SKIP] {filename} already uploaded")
124
- skipped += 1
125
- continue
126
-
127
- try:
128
- blob = bucket.blob(meta["storage_path"])
129
- blob.upload_from_filename(str(local_path), content_type="application/pdf")
130
-
131
- doc_ref.set(
132
- {
133
- "filename": filename,
134
- "subject": meta["subject"],
135
- "type": meta["type"],
136
- "strand": meta["strand"],
137
- "quarters": meta["quarters"],
138
- "storage_path": meta["storage_path"],
139
- "uploaded_at": firestore.SERVER_TIMESTAMP,
140
- "indexed": False,
141
- }
142
- )
143
-
144
- print(f"[OK] Uploaded {filename}")
145
- uploaded += 1
146
- except Exception as e:
147
- print(f"[ERROR] {filename}: {e}")
148
-
149
- print("-" * 50)
150
- print(f"Upload complete: {uploaded} uploaded, {skipped} skipped")
151
-
152
-
153
- def index_pdfs():
154
- """Extract text from PDFs, chunk, embed, and store in ChromaDB."""
155
- try:
156
- from pypdf import PdfReader
157
- import chromadb
158
- from sentence_transformers import SentenceTransformer
159
- from firebase_admin import firestore
160
- except ImportError:
161
- print("ERROR: Missing dependencies. Run: pip install pypdf chromadb sentence-transformers firebase-admin")
162
- return
163
-
164
- chroma_path = os.getenv("CHROMA_PERSIST_PATH", "./datasets/vectorstore")
165
-
166
- chroma_client = chromadb.PersistentClient(path=chroma_path)
167
- collection = chroma_client.get_or_create_collection(
168
- name="curriculum_chunks",
169
- metadata={"hnsw:space": "cosine"},
170
- )
171
- embedder = SentenceTransformer("BAAI/bge-base-en-v1.5")
172
-
173
- try:
174
- import firebase_admin
175
- from firebase_admin import firestore as FS
176
- db = FS.client()
177
- except Exception:
178
- db = None
179
-
180
- print(f"Indexing PDFs from: {LOCAL_PDF_DIR}")
181
- print("-" * 50)
182
-
183
- indexed = 0
184
- skipped = 0
185
-
186
- for filename, meta in PDF_METADATA.items():
187
- if db:
188
- doc_ref = db.collection("curriculumDocs").document(filename)
189
- doc = doc_ref.get()
190
- if doc and doc.to_dict().get("indexed", False):
191
- print(f"[SKIP] {filename} already indexed")
192
- skipped += 1
193
- continue
194
-
195
- local_path = Path(LOCAL_PDF_DIR) / filename
196
- if not local_path.exists():
197
- print(f"[SKIP] {filename} not found")
198
- skipped += 1
199
- continue
200
-
201
- try:
202
- reader = PdfReader(str(local_path))
203
- full_text = "\n".join(page.extract_text() or "" for page in reader.pages)
204
-
205
- if not full_text.strip():
206
- print(f"[WARN] {filename} has no extractable text")
207
- continue
208
-
209
- chunks = chunk_text(full_text)
210
- print(f"[INFO] {filename} -> {len(chunks)} chunks")
211
-
212
- for i, chunk in enumerate(chunks):
213
- chunk_id = f"{filename}_chunk_{i}"
214
-
215
- existing = collection.get(ids=[chunk_id])
216
- if existing and existing.get("ids"):
217
- continue
218
-
219
- chunk_embedding = embedder.encode(
220
- chunk,
221
- normalize_embeddings=True,
222
- ).tolist()
223
-
224
- collection.add(
225
- embeddings=[chunk_embedding],
226
- documents=[chunk],
227
- metadatas=[
228
- {
229
- "source_file": filename,
230
- "subject": meta["subject"],
231
- "strand": ",".join(meta["strand"]),
232
- "quarter": ",".join(meta["quarters"]),
233
- "chunk_index": i,
234
- "type": meta["type"],
235
- }
236
- ],
237
- ids=[chunk_id],
238
- )
239
-
240
- if db:
241
- doc_ref.update({"indexed": True})
242
-
243
- print(f"[OK] Indexed {filename}")
244
- indexed += 1
245
- except Exception as e:
246
- print(f"[ERROR] {filename}: {e}")
247
-
248
- print("-" * 50)
249
- print(f"Indexing complete: {indexed} indexed, {skipped} skipped")
250
- print(f"Total chunks in ChromaDB: {collection.count()}")
251
-
252
-
253
- if __name__ == "__main__":
254
- import argparse
255
-
256
- parser = argparse.ArgumentParser(description="Upload and index DepEd curriculum PDFs")
257
- parser.add_argument("action", choices=["upload", "index", "both"], help="Action to perform")
258
- args = parser.parse_args()
259
-
260
- if args.action in ("upload", "both"):
261
- upload_pdfs()
262
-
263
- if args.action in ("index", "both"):
264
- index_pdfs()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/upload_lesson_modules.py DELETED
@@ -1,142 +0,0 @@
1
- """
2
- Merge DepEd lesson module PDFs and upload to Firebase Storage.
3
- Run: python backend/scripts/upload_lesson_modules.py
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import os
9
- import sys
10
- from pathlib import Path
11
-
12
- sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
13
-
14
- from pypdf import PdfWriter, PdfReader
15
-
16
- LOCAL_MODULES_DIR = Path(__file__).resolve().parents[1].parent / "datasets" / "lesson_modules"
17
- FIREBASE_STORAGE_BUCKET = "mathpulse-ai-2026.firebasestorage.app"
18
-
19
- # Upload plan
20
- UPLOAD_JOBS = [
21
- {
22
- "id": "basic-calc-q3",
23
- "display_name": "Basic Calculus Q3",
24
- "subject": "Basic Calculus",
25
- "subjectId": "basic-calc",
26
- "quarter": 3,
27
- "storage_path": "curriculum/basic_calc/SDO_Navotas_BasicCalc_SHS_Q3.FV.pdf",
28
- "local_dir": LOCAL_MODULES_DIR / "basic_calculus_q3",
29
- "filename": "Basic Calculus-Q3-Module-{n}.pdf",
30
- "modules": list(range(1, 9)), # Modules 1-8
31
- },
32
- {
33
- "id": "gen-math-q2",
34
- "display_name": "General Mathematics Q2",
35
- "subject": "General Mathematics",
36
- "subjectId": "gen-math",
37
- "quarter": 2,
38
- "storage_path": "curriculum/gen_math_q2/SDO_Navotas_GenMath_SHS_Q2.FV.pdf",
39
- "local_dir": LOCAL_MODULES_DIR / "genmath_q2",
40
- "filename": "genmath_q2_mod{n}_*.pdf",
41
- "modules": [2, 3], # Modules 2 and 3 only
42
- },
43
- ]
44
-
45
-
46
- def merge_pdfs(job: dict) -> Path | None:
47
- """Merge multiple PDFs into a single output file. Returns output path."""
48
- output_dir = LOCAL_MODULES_DIR / "merged"
49
- output_dir.mkdir(parents=True, exist_ok=True)
50
- output_path = output_dir / f"{job['id']}_merged.pdf"
51
-
52
- writer = PdfWriter()
53
-
54
- for mod_num in job["modules"]:
55
- if job["id"] == "basic-calc-q3":
56
- fname = job["filename"].format(n=mod_num)
57
- else:
58
- # GenMath modules have specific naming
59
- fname = None
60
- pattern = job["filename"].format(n=mod_num)
61
- for f in job["local_dir"].glob(pattern):
62
- fname = f.name
63
- break
64
- if fname is None:
65
- print(f" [WARN] Could not find file for module {mod_num}")
66
- continue
67
-
68
- src_path = job["local_dir"] / fname
69
- if not src_path.exists():
70
- print(f" [WARN] File not found: {src_path}")
71
- continue
72
-
73
- reader = PdfReader(str(src_path))
74
- print(f" + {src_path.name} ({len(reader.pages)} pages)")
75
- for page in reader.pages:
76
- writer.add_page(page)
77
-
78
- print(f" Writing {output_path.name} ({len(writer.pages)} total pages)")
79
- with open(output_path, "wb") as f:
80
- writer.write(f)
81
-
82
- return output_path
83
-
84
-
85
- def upload_to_firebase(local_path: Path, storage_path: str) -> bool:
86
- """Upload a PDF file to Firebase Storage."""
87
- try:
88
- import firebase_admin
89
- from firebase_admin import credentials, storage
90
- except ImportError:
91
- print(" ERROR: firebase-admin not installed")
92
- return False
93
-
94
- sa_file = Path(__file__).resolve().parents[1].parent / ".secrets" / "firebase-service-account.json"
95
- if not sa_file.exists():
96
- print(f" ERROR: Service account not found at {sa_file}")
97
- return False
98
-
99
- if not firebase_admin._apps:
100
- cred = credentials.Certificate(str(sa_file))
101
- firebase_admin.initialize_app(cred, {"storageBucket": FIREBASE_STORAGE_BUCKET})
102
-
103
- bucket = storage.bucket()
104
- blob = bucket.blob(storage_path)
105
-
106
- print(f" Uploading to gs://{bucket.name}/{storage_path}")
107
- blob.upload_from_filename(str(local_path), content_type="application/pdf")
108
- print(f" Upload complete!")
109
- return True
110
-
111
-
112
- def main():
113
- print("=" * 60)
114
- print("MathPulse AI — Lesson Module PDF Uploader")
115
- print("=" * 60)
116
-
117
- for job in UPLOAD_JOBS:
118
- print(f"\n[{job['display_name']}]")
119
- print("-" * 40)
120
-
121
- # Step 1: Merge PDFs
122
- output_path = merge_pdfs(job)
123
- if not output_path or not output_path.exists():
124
- print(f" [FAIL] Merge failed for {job['id']}")
125
- continue
126
-
127
- # Step 2: Upload to Firebase
128
- success = upload_to_firebase(output_path, job["storage_path"])
129
- if not success:
130
- print(f" [FAIL] Upload failed for {job['id']}")
131
- continue
132
-
133
- print(f"\n SUCCESS: {job['display_name']}")
134
- print(f" Storage path: gs://{FIREBASE_STORAGE_BUCKET}/{job['storage_path']}")
135
- print(f" Pages: {len(PdfReader(str(output_path)).pages)}")
136
-
137
- print("\n" + "=" * 60)
138
- print("Done!")
139
-
140
-
141
- if __name__ == "__main__":
142
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/upload_vectorstore_to_firebase.py DELETED
@@ -1,71 +0,0 @@
1
- """
2
- Upload vectorstore directory to Firebase Storage.
3
- Run: python -m backend.scripts.upload_vectorstore_to_firebase
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- import logging
9
- import os
10
- import sys
11
- from pathlib import Path
12
-
13
- logger = logging.getLogger("mathpulse.upload_vectorstore")
14
-
15
- sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
16
-
17
- from backend.rag.firebase_storage_loader import _init_firebase_storage
18
-
19
- VECTORSTORE_SOURCE_DIR = Path(__file__).resolve().parents[3] / "datasets" / "vectorstore"
20
- REMOTE_PREFIX = "vectorstore/"
21
-
22
-
23
- def upload_directory(local_dir: Path, bucket, prefix: str):
24
- """Recursively upload a local directory to Firebase Storage prefix."""
25
- uploaded = 0
26
- skipped = 0
27
-
28
- for root, dirs, files in os.walk(local_dir):
29
- for filename in files:
30
- local_path = Path(root) / filename
31
- relative_path = local_path.relative_to(local_dir)
32
- remote_path = f"{prefix}{relative_path.as_posix()}"
33
-
34
- try:
35
- blob = bucket.blob(remote_path)
36
- blob.upload_from_filename(str(local_path))
37
- logger.info("Uploaded: %s (%d bytes)", remote_path, local_path.stat().st_size)
38
- uploaded += 1
39
- except Exception as e:
40
- logger.error("Failed to upload %s: %s", remote_path, e)
41
- skipped += 1
42
-
43
- return uploaded, skipped
44
-
45
-
46
- if __name__ == "__main__":
47
- import argparse
48
-
49
- logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
50
-
51
- parser = argparse.ArgumentParser(description="Upload vectorstore to Firebase Storage")
52
- parser.add_argument("--source", type=str, default=str(VECTORSTORE_SOURCE_DIR),
53
- help="Local vectorstore directory")
54
- parser.add_argument("--prefix", type=str, default=REMOTE_PREFIX,
55
- help="Remote path prefix in Firebase Storage")
56
- args = parser.parse_args()
57
-
58
- source_dir = Path(args.source)
59
- if not source_dir.exists():
60
- logger.error("Source directory does not exist: %s", source_dir)
61
- sys.exit(1)
62
-
63
- _, bucket = _init_firebase_storage()
64
- if bucket is None:
65
- logger.error("Firebase Storage not available")
66
- sys.exit(1)
67
-
68
- logger.info("Uploading vectorstore from %s to gs://%s/%s",
69
- source_dir, bucket.name, args.prefix)
70
- uploaded, skipped = upload_directory(source_dir, bucket, args.prefix)
71
- logger.info("Upload complete: %d uploaded, %d skipped", uploaded, skipped)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/__init__.py CHANGED
@@ -1,44 +1 @@
1
  """Backend service helpers for inference, logging, and integrations."""
2
-
3
- from .inference_client import (
4
- create_default_client,
5
- InferenceRequest,
6
- InferenceClient,
7
- is_sequential_model,
8
- get_current_runtime_config,
9
- get_model_for_task,
10
- set_runtime_model_profile,
11
- set_runtime_model_override,
12
- reset_runtime_overrides,
13
- model_supports_thinking,
14
- _MODEL_PROFILES,
15
- )
16
-
17
- from .ai_client import (
18
- get_deepseek_client,
19
- CHAT_MODEL,
20
- REASONER_MODEL,
21
- APIError,
22
- RateLimitError,
23
- APITimeoutError,
24
- )
25
-
26
- __all__ = [
27
- "create_default_client",
28
- "InferenceRequest",
29
- "InferenceClient",
30
- "is_sequential_model",
31
- "get_current_runtime_config",
32
- "get_model_for_task",
33
- "set_runtime_model_profile",
34
- "set_runtime_model_override",
35
- "reset_runtime_overrides",
36
- "model_supports_thinking",
37
- "_MODEL_PROFILES",
38
- "get_deepseek_client",
39
- "CHAT_MODEL",
40
- "REASONER_MODEL",
41
- "APIError",
42
- "RateLimitError",
43
- "APITimeoutError",
44
- ]
 
1
  """Backend service helpers for inference, logging, and integrations."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/ai_client.py DELETED
@@ -1,28 +0,0 @@
1
- import os
2
- from openai import OpenAI, APIError, RateLimitError, APITimeoutError
3
- from functools import lru_cache
4
-
5
- __all__ = [
6
- "get_deepseek_client",
7
- "CHAT_MODEL",
8
- "REASONER_MODEL",
9
- "DEEPSEEK_BASE_URL",
10
- "APIError",
11
- "RateLimitError",
12
- "APITimeoutError",
13
- ]
14
-
15
- DEEPSEEK_BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
16
- CHAT_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
17
- REASONER_MODEL = os.getenv("DEEPSEEK_REASONER_MODEL", "deepseek-reasoner")
18
-
19
-
20
- @lru_cache(maxsize=1)
21
- def get_deepseek_client() -> OpenAI:
22
- api_key = os.getenv("DEEPSEEK_API_KEY")
23
- if not api_key:
24
- raise ValueError("DEEPSEEK_API_KEY environment variable not set")
25
- return OpenAI(
26
- api_key=api_key,
27
- base_url=DEEPSEEK_BASE_URL,
28
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/curriculum_service.py DELETED
@@ -1,232 +0,0 @@
1
- """
2
- Curriculum Service - Firestore-backed curriculum data.
3
-
4
- Fetches subjects, topics, and modules from Firestore.
5
- Falls back to static data if Firestore is unavailable.
6
- """
7
-
8
- import logging
9
- import os
10
- from typing import Any, Dict, List, Optional
11
-
12
- logger = logging.getLogger(__name__)
13
-
14
- # Static curriculum data as fallback
15
- _STATIC_SUBJECTS = [
16
- {
17
- "id": "gen-math",
18
- "code": "GEN MATH",
19
- "name": "General Mathematics",
20
- "gradeLevel": "Grade 11",
21
- "semester": "1st Semester",
22
- "color": "from-blue-500 to-cyan-500",
23
- "pdfAvailable": True,
24
- "topics": [
25
- {"id": "gen-math-001", "name": "Patterns and Real-Life Relationships", "unit": "Patterns, Relations, and Functions"},
26
- {"id": "gen-math-002", "name": "Functions as Mathematical Models", "unit": "Patterns, Relations, and Functions"},
27
- {"id": "gen-math-003", "name": "Function Notation and Evaluation", "unit": "Patterns, Relations, and Functions"},
28
- {"id": "gen-math-004", "name": "Domain and Range of Functions", "unit": "Patterns, Relations, and Functions"},
29
- {"id": "gen-math-005", "name": "Operations on Functions", "unit": "Patterns, Relations, and Functions"},
30
- {"id": "gen-math-006", "name": "Composite Functions", "unit": "Patterns, Relations, and Functions"},
31
- {"id": "gen-math-007", "name": "Inverse Functions", "unit": "Patterns, Relations, and Functions"},
32
- {"id": "gen-math-008", "name": "Graphs of Rational Functions", "unit": "Patterns, Relations, and Functions"},
33
- {"id": "gen-math-009", "name": "Graphs of Exponential Functions", "unit": "Patterns, Relations, and Functions"},
34
- {"id": "gen-math-010", "name": "Graphs of Logarithmic Functions", "unit": "Patterns, Relations, and Functions"},
35
- {"id": "gen-math-011", "name": "Simple and Compound Interest", "unit": "Financial Mathematics"},
36
- {"id": "gen-math-012", "name": "Simple and General Annuities", "unit": "Financial Mathematics"},
37
- {"id": "gen-math-013", "name": "Present and Future Value", "unit": "Financial Mathematics"},
38
- {"id": "gen-math-014", "name": "Loans, Amortization, and Sinking Funds", "unit": "Financial Mathematics"},
39
- {"id": "gen-math-015", "name": "Stocks, Bonds, and Market Indices", "unit": "Financial Mathematics"},
40
- {"id": "gen-math-016", "name": "Business Decision-Making with Mathematical Models", "unit": "Financial Mathematics"},
41
- {"id": "gen-math-017", "name": "Propositions and Logical Connectives", "unit": "Logic and Mathematical Reasoning"},
42
- {"id": "gen-math-018", "name": "Truth Values and Truth Tables", "unit": "Logic and Mathematical Reasoning"},
43
- {"id": "gen-math-019", "name": "Logical Equivalence and Implication", "unit": "Logic and Mathematical Reasoning"},
44
- {"id": "gen-math-020", "name": "Quantifiers and Negation", "unit": "Logic and Mathematical Reasoning"},
45
- {"id": "gen-math-021", "name": "Validity of Arguments", "unit": "Logic and Mathematical Reasoning"},
46
- ]
47
- },
48
- {
49
- "id": "stats-prob",
50
- "code": "STAT&PROB",
51
- "name": "Statistics and Probability",
52
- "gradeLevel": "Grade 11",
53
- "semester": "2nd Semester",
54
- "color": "from-sky-500 to-cyan-500",
55
- "pdfAvailable": True,
56
- "topics": [
57
- {"id": "stat-001", "name": "Random Variables", "unit": "Random Variables"},
58
- {"id": "stat-002", "name": "Discrete Probability Distributions", "unit": "Random Variables"},
59
- {"id": "stat-003", "name": "Mean and Variance of Discrete RV", "unit": "Random Variables"},
60
- {"id": "stat-004", "name": "Normal Distribution", "unit": "Normal Distribution"},
61
- {"id": "stat-005", "name": "Standard Normal Distribution and Z-scores", "unit": "Normal Distribution"},
62
- {"id": "stat-006", "name": "Areas Under the Normal Curve", "unit": "Normal Distribution"},
63
- {"id": "stat-007", "name": "Sampling Distributions", "unit": "Sampling and Estimation"},
64
- {"id": "stat-008", "name": "Central Limit Theorem", "unit": "Sampling and Estimation"},
65
- {"id": "stat-009", "name": "Point Estimation", "unit": "Sampling and Estimation"},
66
- {"id": "stat-010", "name": "Confidence Intervals", "unit": "Sampling and Estimation"},
67
- {"id": "stat-011", "name": "Hypothesis Testing Concepts", "unit": "Hypothesis Testing"},
68
- {"id": "stat-012", "name": "T-test", "unit": "Hypothesis Testing"},
69
- {"id": "stat-013", "name": "Z-test", "unit": "Hypothesis Testing"},
70
- {"id": "stat-014", "name": "Correlation and Regression", "unit": "Correlation and Regression"},
71
- ]
72
- },
73
- {
74
- "id": "pre-calc",
75
- "code": "PRE-CALC",
76
- "name": "Pre-Calculus",
77
- "gradeLevel": "Grade 12",
78
- "semester": "1st Semester",
79
- "color": "from-orange-500 to-red-500",
80
- "pdfAvailable": False,
81
- "topics": [
82
- {"id": "pre-calc-001", "name": "Conic Sections - Parabola", "unit": "Analytic Geometry"},
83
- {"id": "pre-calc-002", "name": "Conic Sections - Ellipse", "unit": "Analytic Geometry"},
84
- {"id": "pre-calc-003", "name": "Conic Sections - Hyperbola", "unit": "Analytic Geometry"},
85
- {"id": "pre-calc-004", "name": "Conic Sections - Circle", "unit": "Analytic Geometry"},
86
- {"id": "pre-calc-005", "name": "Systems of Nonlinear Equations", "unit": "Analytic Geometry"},
87
- {"id": "pre-calc-006", "name": "Sequences and Series", "unit": "Series and Induction"},
88
- {"id": "pre-calc-007", "name": "Arithmetic Sequences", "unit": "Series and Induction"},
89
- {"id": "pre-calc-008", "name": "Geometric Sequences", "unit": "Series and Induction"},
90
- {"id": "pre-calc-009", "name": "Mathematical Induction", "unit": "Series and Induction"},
91
- {"id": "pre-calc-010", "name": "Binomial Theorem", "unit": "Series and Induction"},
92
- {"id": "pre-calc-011", "name": "Angles and Unit Circle", "unit": "Trigonometry"},
93
- {"id": "pre-calc-012", "name": "Trigonometric Functions", "unit": "Trigonometry"},
94
- {"id": "pre-calc-013", "name": "Trigonometric Identities", "unit": "Trigonometry"},
95
- {"id": "pre-calc-014", "name": "Sum and Difference Formulas", "unit": "Trigonometry"},
96
- {"id": "pre-calc-015", "name": "Inverse Trigonometric Functions", "unit": "Trigonometry"},
97
- {"id": "pre-calc-016", "name": "Polar Coordinates", "unit": "Trigonometry"},
98
- ]
99
- },
100
- {
101
- "id": "basic-calc",
102
- "code": "BASIC CALC",
103
- "name": "Basic Calculus",
104
- "gradeLevel": "Grade 12",
105
- "semester": "2nd Semester",
106
- "color": "from-green-500 to-teal-500",
107
- "pdfAvailable": True,
108
- "topics": [
109
- {"id": "calc-001", "name": "Limits of Functions", "unit": "Limits"},
110
- {"id": "calc-002", "name": "Limit Theorems", "unit": "Limits"},
111
- {"id": "calc-003", "name": "One-Sided Limits", "unit": "Limits"},
112
- {"id": "calc-004", "name": "Infinite Limits and Limits at Infinity", "unit": "Limits"},
113
- {"id": "calc-005", "name": "Continuity of Functions", "unit": "Limits"},
114
- {"id": "calc-006", "name": "Definition of the Derivative", "unit": "Derivatives"},
115
- {"id": "calc-007", "name": "Differentiation Rules", "unit": "Derivatives"},
116
- {"id": "calc-008", "name": "Chain Rule", "unit": "Derivatives"},
117
- {"id": "calc-009", "name": "Implicit Differentiation", "unit": "Derivatives"},
118
- {"id": "calc-010", "name": "Higher-Order Derivatives", "unit": "Derivatives"},
119
- {"id": "calc-011", "name": "Related Rates", "unit": "Derivatives"},
120
- {"id": "calc-012", "name": "Extrema and the First Derivative Test", "unit": "Derivatives"},
121
- {"id": "calc-013", "name": "Concavity and the Second Derivative Test", "unit": "Derivatives"},
122
- {"id": "calc-014", "name": "Optimization Problems", "unit": "Derivatives"},
123
- {"id": "calc-015", "name": "Antiderivatives and Indefinite Integrals", "unit": "Integration"},
124
- {"id": "calc-016", "name": "Definite Integrals and the FTC", "unit": "Integration"},
125
- {"id": "calc-017", "name": "Integration by Substitution", "unit": "Integration"},
126
- {"id": "calc-018", "name": "Area Under a Curve", "unit": "Integration"},
127
- ]
128
- },
129
- ]
130
-
131
- _firestore_db = None
132
-
133
-
134
- def _get_firestore_db():
135
- """Initialize Firestore client."""
136
- global _firestore_db
137
- if _firestore_db is not None:
138
- return _firestore_db
139
-
140
- try:
141
- import firebase_admin
142
- from firebase_admin import firestore
143
- if not firebase_admin._apps:
144
- # Try service account from env or default credentials
145
- import json
146
- svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
147
- if svc_account:
148
- sa_creds = json.loads(svc_account)
149
- firebase_admin.initialize_app(firebase_admin.Certificate(sa_creds))
150
- else:
151
- firebase_admin.initialize_app()
152
- _firestore_db = firestore.client()
153
- return _firestore_db
154
- except Exception as e:
155
- logger.warning(f"Could not initialize Firestore: {e}")
156
- return None
157
-
158
-
159
- def get_subjects(grade_level: Optional[str] = None) -> List[Dict[str, Any]]:
160
- """
161
- Fetch all subjects from Firestore.
162
- Falls back to static data if Firestore unavailable.
163
- Defaults to Grade 11 (SHS) if no grade specified.
164
- """
165
- # Default to Grade 11 (SHS) - only serve Grade 11 students for now
166
- if grade_level is None:
167
- grade_level = "Grade 11"
168
-
169
- db = _get_firestore_db()
170
-
171
- if db is not None:
172
- try:
173
- subjects_ref = db.collection("subjects")
174
- if grade_level:
175
- subjects_ref = subjects_ref.where("gradeLevel", "==", grade_level)
176
-
177
- docs = subjects_ref.stream()
178
- subjects = []
179
- for doc in docs:
180
- data = doc.to_dict()
181
- if data:
182
- data["id"] = doc.id
183
- subjects.append(data)
184
-
185
- if subjects:
186
- logger.info(f"Loaded {len(subjects)} subjects from Firestore")
187
- return subjects
188
- except Exception as e:
189
- logger.warning(f"Firestore fetch failed, using static data: {e}")
190
-
191
- # Static fallback
192
- if grade_level:
193
- return [s for s in _STATIC_SUBJECTS if s.get("gradeLevel") == grade_level]
194
- return list(_STATIC_SUBJECTS)
195
-
196
-
197
- def get_subject(subject_id: str) -> Optional[Dict[str, Any]]:
198
- """Fetch a single subject by ID."""
199
- db = _get_firestore_db()
200
-
201
- if db is not None:
202
- try:
203
- doc = db.collection("subjects").document(subject_id).get()
204
- if doc.exists:
205
- data = doc.to_dict()
206
- data["id"] = doc.id
207
- return data
208
- except Exception as e:
209
- logger.warning(f"Firestore fetch failed for {subject_id}: {e}")
210
-
211
- # Static fallback
212
- for subject in _STATIC_SUBJECTS:
213
- if subject["id"] == subject_id:
214
- return dict(subject)
215
- return None
216
-
217
-
218
- def get_topics(subject_id: str) -> List[Dict[str, Any]]:
219
- """Fetch all topics for a subject."""
220
- subject = get_subject(subject_id)
221
- if subject:
222
- return subject.get("topics", [])
223
- return []
224
-
225
-
226
- def get_topic(subject_id: str, topic_id: str) -> Optional[Dict[str, Any]]:
227
- """Fetch a single topic."""
228
- topics = get_topics(subject_id)
229
- for topic in topics:
230
- if topic["id"] == topic_id:
231
- return topic
232
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/inference_client.py CHANGED
@@ -10,198 +10,13 @@ from typing import Any, Dict, List, Optional, Tuple
10
 
11
  import requests
12
  import yaml
13
- from 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")
19
  TEMP_CHAT_MODEL_OVERRIDE_ENV = "INFERENCE_CHAT_MODEL_TEMP_OVERRIDE"
20
 
21
- # ── Model Profiles ────────────────────────────────────────────────────────────
22
- # A profile sets multiple env defaults in one shot.
23
- # Individual env vars (DEEPSEEK_MODEL, DEEPSEEK_REASONER_MODEL, etc.) still override.
24
- # Usage: MODEL_PROFILE=dev or MODEL_PROFILE=prod or MODEL_PROFILE=budget
25
- # Profiles can also be applied at runtime via the admin panel without restart.
26
-
27
- _MODEL_PROFILES: dict[str, dict[str, str]] = {
28
- "dev": {
29
- "INFERENCE_MODEL_ID": CHAT_MODEL,
30
- "INFERENCE_CHAT_MODEL_ID": CHAT_MODEL,
31
- "HF_QUIZ_MODEL_ID": CHAT_MODEL,
32
- "HF_RAG_MODEL_ID": CHAT_MODEL,
33
- "INFERENCE_LOCK_MODEL_ID": CHAT_MODEL,
34
- },
35
- "prod": {
36
- "INFERENCE_MODEL_ID": CHAT_MODEL,
37
- "INFERENCE_CHAT_MODEL_ID": CHAT_MODEL,
38
- "HF_QUIZ_MODEL_ID": CHAT_MODEL,
39
- "HF_RAG_MODEL_ID": REASONER_MODEL,
40
- "INFERENCE_LOCK_MODEL_ID": CHAT_MODEL,
41
- },
42
- "budget": {
43
- "INFERENCE_MODEL_ID": CHAT_MODEL,
44
- "INFERENCE_CHAT_MODEL_ID": CHAT_MODEL,
45
- "HF_QUIZ_MODEL_ID": CHAT_MODEL,
46
- "HF_RAG_MODEL_ID": CHAT_MODEL,
47
- "INFERENCE_LOCK_MODEL_ID": CHAT_MODEL,
48
- },
49
- }
50
-
51
- # ── Runtime Override Store ────────────────────────────────────────────────────
52
- # Mutated at runtime by the admin panel via /api/admin/model-config.
53
- # Priority: above env vars, below INFERENCE_ENFORCE_LOCK_MODEL.
54
- # Persisted to Firestore so backend cold-restarts restore the last admin-set config.
55
-
56
- _RUNTIME_OVERRIDES: dict[str, str] = {}
57
- _RUNTIME_PROFILE: str = ""
58
-
59
- _FS_COLLECTION = "system_config"
60
- _FS_DOC = "active_model_config"
61
-
62
-
63
- def _save_runtime_config_to_firestore() -> None:
64
- try:
65
- from firebase_admin import firestore as fs
66
-
67
- db = fs.client()
68
- db.collection(_FS_COLLECTION).document(_FS_DOC).set(
69
- {
70
- "profile": _RUNTIME_PROFILE,
71
- "overrides": _RUNTIME_OVERRIDES,
72
- "updatedAt": fs.SERVER_TIMESTAMP,
73
- }
74
- )
75
- except Exception as e:
76
- LOGGER.warning("Could not persist model config to Firestore: %s", e)
77
-
78
-
79
- def _load_runtime_config_from_firestore() -> None:
80
- try:
81
- from firebase_admin import firestore as fs
82
-
83
- db = fs.client()
84
- doc = db.collection(_FS_COLLECTION).document(_FS_DOC).get()
85
- if not doc.exists:
86
- return
87
- data = doc.to_dict() or {}
88
- profile = str(data.get("profile", "")).strip().lower()
89
- overrides = data.get("overrides", {})
90
- if profile and profile in _MODEL_PROFILES:
91
- global _RUNTIME_PROFILE
92
- _RUNTIME_PROFILE = profile
93
- _RUNTIME_OVERRIDES.clear()
94
- _RUNTIME_OVERRIDES.update(_MODEL_PROFILES[profile])
95
- if isinstance(overrides, dict):
96
- for key, value in overrides.items():
97
- _RUNTIME_OVERRIDES[str(key)] = str(value)
98
- LOGGER.info("Restored runtime model config from Firestore: profile=%s", profile)
99
- except ImportError:
100
- LOGGER.debug("Firebase not available (optional for DeepSeek-only)")
101
- except Exception as e:
102
- LOGGER.warning("Could not restore model config from Firestore: %s", e)
103
-
104
-
105
- def _apply_model_profile() -> None:
106
- profile_name = os.getenv("MODEL_PROFILE", "").strip().lower()
107
- if not profile_name:
108
- return
109
- profile = _MODEL_PROFILES.get(profile_name)
110
- if profile is None:
111
- LOGGER.warning("MODEL_PROFILE='%s' is not a known profile.", profile_name)
112
- return
113
- for key, value in profile.items():
114
- if not os.environ.get(key):
115
- os.environ[key] = value
116
- LOGGER.info("Startup model profile applied: %s", profile_name)
117
-
118
-
119
- _apply_model_profile()
120
- _load_runtime_config_from_firestore()
121
-
122
-
123
- def set_runtime_model_profile(profile_name: str) -> None:
124
- """Apply a named profile at runtime without restarting the process."""
125
- global _RUNTIME_PROFILE, _RUNTIME_OVERRIDES
126
- normalized = profile_name.strip().lower()
127
- profile = _MODEL_PROFILES.get(normalized)
128
- if not profile:
129
- raise ValueError(
130
- f"Unknown profile: '{profile_name}'. Valid values: {list(_MODEL_PROFILES.keys())}"
131
- )
132
- _RUNTIME_PROFILE = normalized
133
- _RUNTIME_OVERRIDES.clear()
134
- _RUNTIME_OVERRIDES.update(profile)
135
- LOGGER.info("Runtime model profile switched to: %s", profile_name)
136
- _save_runtime_config_to_firestore()
137
-
138
-
139
- def set_runtime_model_override(key: str, value: str) -> None:
140
- """Set a single model env key at runtime."""
141
- _RUNTIME_OVERRIDES[key] = value
142
- LOGGER.info("Runtime model override set: %s = %s", key, value)
143
- _save_runtime_config_to_firestore()
144
-
145
-
146
- def reset_runtime_overrides() -> None:
147
- """Clear all runtime overrides."""
148
- global _RUNTIME_PROFILE
149
- _RUNTIME_OVERRIDES.clear()
150
- _RUNTIME_PROFILE = ""
151
- LOGGER.info("Runtime model overrides cleared.")
152
- _save_runtime_config_to_firestore()
153
-
154
-
155
- def get_current_runtime_config() -> dict:
156
- resolved: dict[str, str] = {}
157
- for key in {
158
- "INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
159
- "HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
160
- }:
161
- resolved[key] = _resolve_key(key)
162
- return {
163
- "profile": _RUNTIME_PROFILE,
164
- "overrides": dict(_RUNTIME_OVERRIDES),
165
- "resolved": resolved,
166
- }
167
-
168
-
169
- def _resolve_key(key: str) -> str:
170
- if value := _RUNTIME_OVERRIDES.get(key):
171
- return value
172
- if _RUNTIME_PROFILE and _RUNTIME_PROFILE in _MODEL_PROFILES:
173
- if value := _MODEL_PROFILES[_RUNTIME_PROFILE].get(key):
174
- return value
175
- return os.getenv(key, "")
176
-
177
-
178
- def get_model_for_task(task_type: str) -> str:
179
- task = (task_type or "default").strip().lower()
180
- enforce_lock = os.getenv("INFERENCE_ENFORCE_LOCK_MODEL", "true").strip().lower() in {"1", "true", "yes", "on"}
181
- if enforce_lock:
182
- override = (
183
- _RUNTIME_OVERRIDES.get("INFERENCE_LOCK_MODEL_ID")
184
- or os.getenv("INFERENCE_LOCK_MODEL_ID")
185
- or CHAT_MODEL
186
- )
187
- return override
188
- task_key_map = {
189
- "chat": "INFERENCE_CHAT_MODEL_ID",
190
- "quiz_generation": "HF_QUIZ_MODEL_ID",
191
- "rag_lesson": "HF_RAG_MODEL_ID",
192
- "rag_problem": "HF_RAG_MODEL_ID",
193
- "rag_analysis_context": "HF_RAG_MODEL_ID",
194
- }
195
- if env_key := task_key_map.get(task):
196
- if resolved := _resolve_key(env_key):
197
- return resolved
198
- return _resolve_key("INFERENCE_MODEL_ID") or CHAT_MODEL
199
-
200
-
201
- def model_supports_thinking(model_id: str = "") -> bool:
202
- mid = (model_id or os.getenv("INFERENCE_MODEL_ID") or "").strip()
203
- return mid == REASONER_MODEL
204
-
205
 
206
  def _normalize_local_space_url(raw_url: str) -> str:
207
  """Accept either hf.space host or huggingface.co/spaces URL for local_space provider."""
@@ -209,6 +24,8 @@ def _normalize_local_space_url(raw_url: str) -> str:
209
  if not cleaned:
210
  return "http://127.0.0.1:7860"
211
 
 
 
212
  match = re.match(r"^https?://huggingface\.co/spaces/([^/]+)/([^/]+)$", cleaned, re.IGNORECASE)
213
  if match:
214
  owner = match.group(1).strip().lower()
@@ -224,41 +41,38 @@ class InferenceRequest:
224
  model: Optional[str] = None
225
  task_type: str = "default"
226
  request_tag: str = ""
227
- max_new_tokens: int = 900
228
  temperature: float = 0.2
229
  top_p: float = 0.9
230
  repetition_penalty: float = 1.15
231
  timeout_sec: Optional[int] = None
232
- enable_thinking: bool = False
233
 
234
 
235
  class InferenceClient:
236
- def __init__(self, firestore_client: Optional[Any] = None) -> None:
237
- self.firestore = firestore_client
238
- self._last_persist_time = 0.0
239
- self._persist_throttle_sec = 30.0
240
-
241
  config_paths = [
242
- Path("./config/models.yaml"),
243
- Path("/config/models.yaml"),
244
- Path("/app/config/models.yaml"),
245
- Path.cwd() / "config" / "models.yaml",
246
- Path(__file__).resolve().parents[2] / "config" / "models.yaml",
247
  ]
248
-
249
  config: Dict[str, object] = {}
250
  config_path = None
251
-
252
  for path in config_paths:
253
  if path.exists():
254
  config_path = path
255
  with path.open("r", encoding="utf-8") as fh:
256
  config = yaml.safe_load(fh) or {}
257
- LOGGER.info(f"??? 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
 
@@ -270,43 +84,69 @@ class InferenceClient:
270
  if isinstance(primary_cfg, dict):
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)
277
- self.ds_reasoner_model = os.getenv("DEEPSEEK_REASONER_MODEL", REASONER_MODEL)
278
-
 
 
 
 
 
 
 
 
 
 
 
279
  self.local_space_url = _normalize_local_space_url(
280
  os.getenv("INFERENCE_LOCAL_SPACE_URL", "http://127.0.0.1:7860")
281
  )
282
  self.local_generate_path = os.getenv("INFERENCE_LOCAL_SPACE_GENERATE_PATH", "/gradio_api/call/generate")
 
 
283
 
284
- self.enforce_lock_model = os.getenv("INFERENCE_ENFORCE_LOCK_MODEL", "true").strip().lower() in {"1", "true", "yes", "on"}
285
- self.lock_model_id = os.getenv("INFERENCE_LOCK_MODEL_ID", CHAT_MODEL).strip() or CHAT_MODEL
286
 
287
- default_model_fallback = str(primary.get("id") or CHAT_MODEL)
288
  env_model_id = os.getenv("INFERENCE_MODEL_ID", "").strip()
289
  self.default_model = env_model_id or default_model_fallback
290
-
291
  default_max_tokens = str(primary.get("max_new_tokens") or 512)
292
  self.default_max_new_tokens = int(os.getenv("INFERENCE_MAX_NEW_TOKENS", default_max_tokens))
293
-
294
  default_temp = str(primary.get("temperature") or 0.2)
295
  self.default_temperature = float(os.getenv("INFERENCE_TEMPERATURE", default_temp))
296
-
297
  default_top_p = str(primary.get("top_p") or 0.9)
298
  self.default_top_p = float(os.getenv("INFERENCE_TOP_P", default_top_p))
299
-
 
300
  self.chat_model_override = os.getenv("INFERENCE_CHAT_MODEL_ID", "").strip()
301
  self.chat_model_temp_override = os.getenv(TEMP_CHAT_MODEL_OVERRIDE_ENV, "").strip()
302
  self.chat_strict_model_only = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
- self.ds_timeout_sec = int(os.getenv("INFERENCE_HF_TIMEOUT_SEC", "90"))
305
  self.local_timeout_sec = int(os.getenv("INFERENCE_LOCAL_SPACE_TIMEOUT_SEC", "90"))
306
  self.max_retries = int(os.getenv("INFERENCE_MAX_RETRIES", "3"))
307
  self.backoff_sec = float(os.getenv("INFERENCE_BACKOFF_SEC", "1.5"))
308
- self.interactive_timeout_sec = int(os.getenv("INFERENCE_INTERACTIVE_TIMEOUT_SEC", str(self.ds_timeout_sec)))
309
- self.background_timeout_sec = int(os.getenv("INFERENCE_BACKGROUND_TIMEOUT_SEC", str(self.ds_timeout_sec)))
310
  self.interactive_max_retries = int(os.getenv("INFERENCE_INTERACTIVE_MAX_RETRIES", str(self.max_retries)))
311
  self.background_max_retries = int(os.getenv("INFERENCE_BACKGROUND_MAX_RETRIES", str(self.max_retries)))
312
  self.interactive_backoff_sec = float(os.getenv("INFERENCE_INTERACTIVE_BACKOFF_SEC", str(self.backoff_sec)))
@@ -327,6 +167,12 @@ class InferenceClient:
327
  )
328
  self.cpu_only_tasks = {v.strip().lower() for v in cpu_tasks_raw.split(",") if v.strip()}
329
 
 
 
 
 
 
 
330
  interactive_tasks_raw = os.getenv(
331
  "INFERENCE_INTERACTIVE_TASKS",
332
  "chat,verify_solution,daily_insight",
@@ -338,20 +184,29 @@ class InferenceClient:
338
  )
339
 
340
  # Default task-to-model routing.
 
341
  self.task_model_map: Dict[str, str] = {
342
- "chat": CHAT_MODEL,
343
- "verify_solution": CHAT_MODEL,
344
- "lesson_generation": CHAT_MODEL,
345
- "quiz_generation": CHAT_MODEL,
346
- "learning_path": CHAT_MODEL,
347
- "daily_insight": CHAT_MODEL,
348
- "risk_classification": CHAT_MODEL,
349
- "risk_narrative": CHAT_MODEL,
350
  }
 
351
  self.task_fallback_model_map: Dict[str, List[str]] = {
352
- "chat": [CHAT_MODEL],
353
- "verify_solution": [CHAT_MODEL],
 
 
 
 
 
 
354
  }
 
355
  self.model_provider_map: Dict[str, str] = {}
356
  self.task_provider_map: Dict[str, str] = {}
357
  if isinstance(config, dict):
@@ -364,6 +219,7 @@ class InferenceClient:
364
  for task, model in task_models.items()
365
  if str(task).strip() and str(model).strip()
366
  }
 
367
  self.task_model_map.update(config_task_models)
368
  task_fallback_models = routing_cfg.get("task_fallback_model_map", {})
369
  if isinstance(task_fallback_models, dict):
@@ -395,7 +251,7 @@ class InferenceClient:
395
  for task_key in list(self.task_model_map.keys()):
396
  self.task_model_map[task_key] = env_model_id
397
  LOGGER.info(
398
- f"???? INFERENCE_MODEL_ID env var override applied: {env_model_id}"
399
  )
400
  LOGGER.info(
401
  f" Task model mappings changed from: {original_map}"
@@ -404,27 +260,29 @@ class InferenceClient:
404
  else:
405
  env_override_note = ""
406
 
407
- if self.enforce_lock_model:
408
- lock_map_before = dict(self.task_model_map)
409
- self.default_model = self.lock_model_id
410
  for task_key in list(self.task_model_map.keys()):
411
- self.task_model_map[task_key] = self.lock_model_id
412
  self.fallback_models = []
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'}")
426
  LOGGER.info(f" Chat strict model lock: {self.chat_strict_model_only}")
427
- LOGGER.info(f" Global model lock: {self.enforce_lock_model}")
428
  LOGGER.info(f" Verify solution model: {self.task_model_map.get('verify_solution', self.default_model)}")
429
  LOGGER.info(f" Full task_model_map: {self.task_model_map}")
430
 
@@ -436,23 +294,18 @@ class InferenceClient:
436
  "requests_error": 0,
437
  "retries_total": 0,
438
  "fallback_attempts": 0,
439
- "latency_sum_ms": 0.0,
440
- "latency_count": 0,
441
  "route_counts": {},
442
  "task_counts": {},
443
  "provider_counts": {},
444
  "status_code_counts": {},
445
  }
446
 
447
- self._load_persistent_metrics()
448
-
449
  def _bump_metric(self, key: str, inc: int = 1) -> None:
450
  with self._metrics_lock:
451
  current = self._metrics.get(key) or 0
452
  if not isinstance(current, int):
453
  current = 0
454
  self._metrics[key] = current + inc
455
- self._persist_metrics()
456
 
457
  def _bump_bucket(self, key: str, bucket: str, inc: int = 1) -> None:
458
  with self._metrics_lock:
@@ -464,50 +317,6 @@ class InferenceClient:
464
  if not isinstance(current, int):
465
  current = 0
466
  mapping[bucket] = current + inc
467
- self._persist_metrics()
468
-
469
- def _record_completion(self, *, latency_ms: float) -> None:
470
- with self._metrics_lock:
471
- self._metrics["latency_sum_ms"] = (self._metrics.get("latency_sum_ms") or 0.0) + latency_ms
472
- self._metrics["latency_count"] = (self._metrics.get("latency_count") or 0) + 1
473
- self._persist_metrics()
474
-
475
- def _load_persistent_metrics(self) -> None:
476
- if not self.firestore:
477
- return
478
- try:
479
- doc_ref = self.firestore.collection("system_metrics").document("inference_stats")
480
- doc = doc_ref.get()
481
- if doc.exists:
482
- data = doc.to_dict() or {}
483
- with self._metrics_lock:
484
- for k, v in data.items():
485
- if k in self._metrics:
486
- if isinstance(v, (int, float)):
487
- self._metrics[k] = v
488
- elif isinstance(v, dict) and isinstance(self._metrics[k], dict):
489
- self._metrics[k].update(v)
490
- LOGGER.info("??? Persistent inference metrics loaded from Firestore")
491
- except Exception as e:
492
- LOGGER.warning(f"?????? Failed to load persistent metrics: {e}")
493
-
494
- def _persist_metrics(self, force: bool = False) -> None:
495
- if not self.firestore:
496
- return
497
-
498
- now = time.time()
499
- if not force and (now - self._last_persist_time < self._persist_throttle_sec):
500
- return
501
-
502
- try:
503
- self._last_persist_time = now
504
- doc_ref = self.firestore.collection("system_metrics").document("inference_stats")
505
- with self._metrics_lock:
506
- snapshot = dict(self._metrics)
507
-
508
- doc_ref.set(snapshot, merge=True)
509
- except Exception as e:
510
- LOGGER.warning(f"?????? Failed to persist metrics: {e}")
511
 
512
  def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int) -> None:
513
  self._bump_metric("requests_total", 1)
@@ -519,10 +328,6 @@ class InferenceClient:
519
 
520
  def snapshot_metrics(self) -> Dict[str, Any]:
521
  with self._metrics_lock:
522
- l_sum = self._metrics.get("latency_sum_ms") or 0.0
523
- l_count = self._metrics.get("latency_count") or 0
524
- avg_latency = round(l_sum / l_count, 2) if l_count > 0 else 0.0
525
-
526
  snapshot = {
527
  "uptime_sec": round(max(0.0, time.time() - self._metrics_started_at), 2),
528
  "requests_total": self._metrics.get("requests_total") or 0,
@@ -530,9 +335,6 @@ class InferenceClient:
530
  "requests_error": self._metrics.get("requests_error") or 0,
531
  "retries_total": self._metrics.get("retries_total") or 0,
532
  "fallback_attempts": self._metrics.get("fallback_attempts") or 0,
533
- "avg_latency_ms": avg_latency,
534
- "active_model": self.default_model,
535
- "primary_provider": self.provider,
536
  "route_counts": dict(self._metrics.get("route_counts") or {}),
537
  "task_counts": dict(self._metrics.get("task_counts") or {}),
538
  "provider_counts": dict(self._metrics.get("provider_counts") or {}),
@@ -544,18 +346,22 @@ class InferenceClient:
544
  effective_task = (req.task_type or "default").strip().lower()
545
  request_tag = req.request_tag.strip() or f"{effective_task}-{int(time.time() * 1000)}"
546
  selected_model, model_selection_source = self._resolve_primary_model(req)
547
-
548
  model_chain = self._model_chain_for_task(effective_task, selected_model)
549
  last_error: Optional[Exception] = None
550
-
551
- 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'}")
558
 
 
559
  for fallback_depth, model_name in enumerate(model_chain):
560
  request_for_model = InferenceRequest(
561
  messages=req.messages,
@@ -568,19 +374,20 @@ class InferenceClient:
568
  repetition_penalty=req.repetition_penalty,
569
  timeout_sec=req.timeout_sec,
570
  )
571
-
572
- 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
 
585
  if last_error:
586
  raise last_error
@@ -593,6 +400,10 @@ class InferenceClient:
593
  effective_task = (req.task_type or "default").strip().lower()
594
  runtime_chat_override = self._runtime_chat_model_override()
595
 
 
 
 
 
596
  if effective_task == "chat" and runtime_chat_override:
597
  selected_model = runtime_chat_override
598
  model_selection_source = "chat_temp_override_env"
@@ -606,39 +417,107 @@ class InferenceClient:
606
  selected_model = self.task_model_map.get(effective_task, self.default_model)
607
  model_selection_source = "task_map"
608
 
609
- if self.enforce_lock_model:
610
- effective_lock_model_id = self.lock_model_id
611
  if effective_task == "chat":
612
- effective_lock_model_id = runtime_chat_override or self.chat_model_override or self.lock_model_id
613
 
614
- selected_base = (selected_model or "").split(":", 1)[0].strip()
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"
622
 
623
  if effective_task == "chat" and self.chat_strict_model_only:
624
  return selected_model, f"{model_selection_source}:chat_strict_model_only"
625
 
 
 
 
 
 
626
  return selected_model, model_selection_source
627
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
628
  def _model_chain_for_task(self, task_type: str, selected_model: str) -> List[str]:
629
  normalized = (task_type or "default").strip().lower()
630
  runtime_chat_override = self._runtime_chat_model_override() if normalized == "chat" else ""
631
- chat_lock_model_id = runtime_chat_override or (self.chat_model_override if normalized == "chat" else "")
632
 
633
- if self.enforce_lock_model:
634
  if normalized == "chat":
635
- locked_model = (chat_lock_model_id or self.lock_model_id or "").strip()
636
  else:
637
- locked_model = (self.lock_model_id or "").strip()
638
  return [locked_model] if locked_model else []
639
 
640
  if normalized == "chat" and self.chat_strict_model_only:
641
- chat_model = (chat_lock_model_id or selected_model or "").strip()
642
  return [chat_model] if chat_model else []
643
 
644
  per_task_candidates = self.task_fallback_model_map.get(task_type, [])
@@ -658,6 +537,34 @@ class InferenceClient:
658
  return deduped[:max_models]
659
  return deduped
660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  def _retry_profile(self, task_type: str) -> Tuple[int, float]:
662
  normalized = (task_type or "default").strip().lower()
663
  if normalized in self.interactive_tasks:
@@ -674,6 +581,20 @@ class InferenceClient:
674
  return self.interactive_timeout_sec
675
  return self.background_timeout_sec
676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str:
678
  parts: List[str] = []
679
  for msg in messages:
@@ -686,9 +607,9 @@ class InferenceClient:
686
  prefix = "SYSTEM"
687
  elif role == "assistant":
688
  prefix = "ASSISTANT"
689
- parts.append(f"{prefix}:\n{content}")
690
  parts.append("ASSISTANT:")
691
- return "\n\n".join(parts)
692
 
693
  def _latest_user_message(self, messages: List[Dict[str, str]]) -> str:
694
  for msg in reversed(messages):
@@ -698,223 +619,160 @@ class InferenceClient:
698
  return content
699
  return self._messages_to_prompt(messages)
700
 
701
- def _call_deepseek(self, req: InferenceRequest, fallback_depth: int) -> str:
702
- """Call DeepSeek API with OpenAI-compatible chat completions."""
703
- if not self.ds_api_key:
704
- raise RuntimeError("DEEPSEEK_API_KEY is not set")
705
-
706
- target_model = req.model or self.default_model
707
- route = "deepseek"
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
-
715
- timeout = self._timeout_for(req, "deepseek")
716
  max_retries, backoff_sec = self._retry_profile(task_type)
 
717
 
718
- client = get_deepseek_client()
719
-
720
- # Build chat completions params
721
- params: Dict[str, Any] = {
722
- "model": target_model,
723
- "messages": req.messages,
724
- "max_tokens": req.max_new_tokens or self.default_max_new_tokens,
725
- }
726
-
727
- if target_model == REASONER_MODEL:
728
- params["max_tokens"] = req.max_new_tokens or 1024
729
- else:
730
- params["temperature"] = req.temperature
731
- params["top_p"] = req.top_p
732
-
733
- # Use JSON mode for quiz generation
734
- if task_type == "quiz_generation" and target_model != REASONER_MODEL:
735
- params["response_format"] = {"type": "json_object"}
736
 
737
- for attempt in range(max_retries):
738
- self._record_attempt(
739
- task_type=task_type,
740
- provider="deepseek",
741
- route=route,
742
- fallback_depth=fallback_depth,
743
- )
744
  start = time.perf_counter()
745
  try:
746
- response = client.chat.completions.create(**params, timeout=timeout)
 
747
  latency_ms = (time.perf_counter() - start) * 1000
748
-
749
- content = response.choices[0].message.content or ""
750
- reasoning = getattr(response.choices[0].message, "reasoning_content", None)
751
-
752
- text = content.strip()
753
- if reasoning:
754
- text = f"{reasoning}\n{text}"
755
-
756
  log_model_call(
757
  LOGGER,
758
- provider="deepseek",
759
- model=target_model,
760
- endpoint=self.ds_base_url,
761
  latency_ms=latency_ms,
762
  input_tokens=None,
763
  output_tokens=None,
764
- status="ok",
 
 
765
  task_type=task_type,
766
- request_tag=req.request_tag,
767
  retry_attempt=attempt + 1,
768
  fallback_depth=fallback_depth,
769
  route=route,
770
  )
771
- self._record_attempt(
772
- task_type=task_type,
773
- provider="deepseek",
774
- route=route,
775
- fallback_depth=fallback_depth,
776
- )
777
- self._record_completion(latency_ms=latency_ms)
778
- self._bump_metric("requests_ok", 1)
779
- return text
780
-
781
- except RateLimitError:
782
- latency_ms = (time.perf_counter() - start) * 1000
783
- if attempt < max_retries - 1:
784
- log_model_call(
785
- LOGGER,
786
- provider="deepseek",
787
- model=target_model,
788
- endpoint=self.ds_base_url,
789
- latency_ms=latency_ms,
790
- input_tokens=None,
791
- output_tokens=None,
792
- status="error",
793
- error_class="RateLimitError",
794
- error_message="rate limited",
795
- task_type=task_type,
796
- request_tag=req.request_tag,
797
- retry_attempt=attempt + 1,
798
- fallback_depth=fallback_depth,
799
- route=route,
800
- )
801
- self._bump_metric("retries_total", 1)
802
- time.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
803
- continue
804
- self._bump_metric("requests_error", 1)
805
- raise RuntimeError("DeepSeek API rate limit reached. Please try again shortly.")
806
-
807
- except APITimeoutError:
808
- latency_ms = (time.perf_counter() - start) * 1000
809
- if attempt < max_retries - 1:
810
- log_model_call(
811
- LOGGER,
812
- provider="deepseek",
813
- model=target_model,
814
- endpoint=self.ds_base_url,
815
- latency_ms=latency_ms,
816
- input_tokens=None,
817
- output_tokens=None,
818
- status="error",
819
- error_class="APITimeoutError",
820
- error_message="timeout",
821
- task_type=task_type,
822
- request_tag=req.request_tag,
823
- retry_attempt=attempt + 1,
824
- fallback_depth=fallback_depth,
825
- route=route,
826
- )
827
- self._bump_metric("retries_total", 1)
828
- time.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
829
- continue
830
- self._bump_metric("requests_error", 1)
831
- raise RuntimeError("DeepSeek API timed out. Please retry.")
832
-
833
- except APIError as e:
834
- latency_ms = (time.perf_counter() - start) * 1000
835
- if attempt < max_retries - 1:
836
- log_model_call(
837
- LOGGER,
838
- provider="deepseek",
839
- model=target_model,
840
- endpoint=self.ds_base_url,
841
- latency_ms=latency_ms,
842
- input_tokens=None,
843
- output_tokens=None,
844
- status="error",
845
- error_class="APIError",
846
- error_message=str(e)[:200],
847
- task_type=task_type,
848
- request_tag=req.request_tag,
849
- retry_attempt=attempt + 1,
850
- fallback_depth=fallback_depth,
851
- route=route,
852
- )
853
- self._bump_metric("retries_total", 1)
854
- time.sleep(backoff_sec * (attempt + 1) * random.uniform(0.9, 1.2))
855
- continue
856
- self._bump_metric("requests_error", 1)
857
- raise RuntimeError(f"DeepSeek API error: {str(e)}")
858
 
859
- except Exception as exc:
860
- latency_ms = (time.perf_counter() - start) * 1000
861
- self._bump_metric("requests_error", 1)
862
  log_model_call(
863
  LOGGER,
864
- provider="deepseek",
865
- model=target_model,
866
- endpoint=self.ds_base_url,
867
  latency_ms=latency_ms,
868
  input_tokens=None,
869
  output_tokens=None,
870
  status="error",
871
- error_class=exc.__class__.__name__,
872
- error_message=str(exc)[:200],
873
  task_type=task_type,
874
- request_tag=req.request_tag,
875
  retry_attempt=attempt + 1,
876
  fallback_depth=fallback_depth,
877
  route=route,
878
  )
879
- raise
 
 
 
 
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}"
886
-
887
- prompt = self._messages_to_prompt(req.messages)
888
- payload: Dict[str, object] = {
889
- "data": [
890
- prompt,
891
- [],
892
- req.temperature,
893
- req.top_p,
894
- req.max_new_tokens,
895
- ]
896
- }
897
- headers = {"Content-Type": "application/json"}
898
-
899
  timeout = self._timeout_for(req, provider)
900
-
901
- self._record_attempt(
902
- task_type=req.task_type,
903
- provider=provider,
904
- route=route,
905
- fallback_depth=fallback_depth,
906
- )
907
  start = time.perf_counter()
908
-
909
  try:
910
- resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
911
  except Exception as exc:
912
  latency_ms = (time.perf_counter() - start) * 1000
 
913
  log_model_call(
914
  LOGGER,
915
- provider=provider,
916
- model=target_model,
917
- endpoint=url,
918
  latency_ms=latency_ms,
919
  input_tokens=None,
920
  output_tokens=None,
@@ -927,10 +785,182 @@ class InferenceClient:
927
  fallback_depth=fallback_depth,
928
  route=route,
929
  )
930
- self._bump_metric("requests_error", 1)
 
 
 
 
 
 
931
  raise
932
 
933
- latency_ms = (time.perf_counter() - start) * 1000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
  self._bump_bucket("status_code_counts", str(resp.status_code), 1)
935
 
936
  if resp.status_code != 200:
@@ -969,7 +999,7 @@ class InferenceClient:
969
  status="ok",
970
  task_type=req.task_type,
971
  request_tag=req.request_tag,
972
- retry_attempt=1,
973
  fallback_depth=fallback_depth,
974
  route=route,
975
  )
@@ -1010,39 +1040,32 @@ class InferenceClient:
1010
 
1011
  def _clean_response_text(self, text: str) -> str:
1012
  """Strip JSON braces, template artifacts, and whitespace from response text."""
 
1013
  text = text.strip()
1014
-
 
1015
  if text.startswith("{") and text.endswith("}"):
1016
  try:
 
1017
  parsed = json.loads(text)
 
1018
  if isinstance(parsed, dict):
1019
  if "content" in parsed:
1020
  text = str(parsed["content"]).strip()
1021
  elif "text" in parsed:
1022
  text = str(parsed["text"]).strip()
1023
  except json.JSONDecodeError:
 
1024
  text = text.strip("{}")
1025
-
 
1026
  if text.startswith("```json") or text.startswith("```"):
1027
  text = re.sub(r"^```(?:json)?", "", text).strip()
1028
  if text.endswith("```"):
1029
  text = text[:-3].strip()
1030
-
1031
  return text.strip()
1032
 
1033
 
1034
- def create_default_client(firestore_client: Optional[Any] = None) -> InferenceClient:
1035
- return InferenceClient(firestore_client=firestore_client)
1036
-
1037
-
1038
- def is_sequential_model(model_id: str = "") -> bool:
1039
- mid = (model_id or os.getenv("INFERENCE_MODEL_ID") or "").strip()
1040
- if not mid:
1041
- return False
1042
- if mid == REASONER_MODEL:
1043
- return True
1044
- if _RUNTIME_OVERRIDES:
1045
- lock = _RUNTIME_OVERRIDES.get("INFERENCE_LOCK_MODEL_ID", "")
1046
- if lock == REASONER_MODEL:
1047
- return True
1048
- return False
 
10
 
11
  import requests
12
  import yaml
13
+ from huggingface_hub import InferenceClient as HFInferenceClient
14
 
 
15
  from .logging_utils import configure_structured_logging, log_model_call
16
 
17
  LOGGER = configure_structured_logging("mathpulse.inference")
18
  TEMP_CHAT_MODEL_OVERRIDE_ENV = "INFERENCE_CHAT_MODEL_TEMP_OVERRIDE"
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  def _normalize_local_space_url(raw_url: str) -> str:
22
  """Accept either hf.space host or huggingface.co/spaces URL for local_space provider."""
 
24
  if not cleaned:
25
  return "http://127.0.0.1:7860"
26
 
27
+ # Convert page URL format to runtime host format:
28
+ # https://huggingface.co/spaces/{owner}/{space} -> https://{owner}-{space}.hf.space
29
  match = re.match(r"^https?://huggingface\.co/spaces/([^/]+)/([^/]+)$", cleaned, re.IGNORECASE)
30
  if match:
31
  owner = match.group(1).strip().lower()
 
41
  model: Optional[str] = None
42
  task_type: str = "default"
43
  request_tag: str = ""
44
+ max_new_tokens: int = 512
45
  temperature: float = 0.2
46
  top_p: float = 0.9
47
  repetition_penalty: float = 1.15
48
  timeout_sec: Optional[int] = None
 
49
 
50
 
51
  class InferenceClient:
52
+ def __init__(self) -> None:
53
+ # Try multiple config paths (HF Space, Docker, local development)
54
+ # The deploy script uploads config/ to the space root
 
 
55
  config_paths = [
56
+ Path("./config/models.yaml"), # Current working directory (most reliable)
57
+ Path("/config/models.yaml"), # HF Space root
58
+ Path("/app/config/models.yaml"), # App directory
59
+ Path.cwd() / "config" / "models.yaml", # CWD with config subdir
60
+ Path(__file__).resolve().parents[2] / "config" / "models.yaml", # Package root
61
  ]
62
+
63
  config: Dict[str, object] = {}
64
  config_path = None
65
+
66
  for path in config_paths:
67
  if path.exists():
68
  config_path = path
69
  with path.open("r", encoding="utf-8") as fh:
70
  config = yaml.safe_load(fh) or {}
71
+ LOGGER.info(f" Loaded config from {config_path}")
72
  break
73
+
74
  if not config_path:
75
+ LOGGER.warning(f"⚠️ Config file not found. Checked: {[str(p) for p in config_paths]}")
76
  LOGGER.warning(f" CWD: {Path.cwd()}")
77
  LOGGER.warning(f" Using hardcoded defaults")
78
 
 
84
  if isinstance(primary_cfg, dict):
85
  primary = primary_cfg
86
 
87
+ self.provider = os.getenv("INFERENCE_PROVIDER", "hf_inference").strip().lower()
88
+ self.pro_provider = os.getenv("INFERENCE_PRO_PROVIDER", "hf_inference").strip().lower()
89
+ self.gpu_provider = os.getenv("INFERENCE_GPU_PROVIDER", "hf_inference").strip().lower()
90
+ self.cpu_provider = os.getenv("INFERENCE_CPU_PROVIDER", "hf_inference").strip().lower()
91
+ self.enable_provider_fallback = os.getenv("INFERENCE_ENABLE_PROVIDER_FALLBACK", "true").strip().lower() in {"1", "true", "yes", "on"}
92
+ self.pro_enabled = os.getenv("INFERENCE_PRO_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
93
+ self.hf_token = os.getenv(
94
+ "HF_TOKEN",
95
+ os.getenv("HUGGING_FACE_API_TOKEN", os.getenv("HUGGINGFACE_API_TOKEN", "")),
96
+ )
97
+ self.hf_base_url = os.getenv("INFERENCE_HF_BASE_URL", "https://router.huggingface.co/hf-inference/models")
98
+ self.hf_chat_url = os.getenv("INFERENCE_HF_CHAT_URL", "https://router.huggingface.co/v1/chat/completions")
99
+
100
+ # Featherless AI for Qwen math models (used as fallback when HF router fails)
101
+ self.featherless_api_key = os.getenv("FEATHERLESS_API_KEY", "")
102
+ self.featherless_chat_url = os.getenv("FEATHERLESS_CHAT_URL", "https://api.featherless.ai/openai/v1/chat/completions")
103
+
104
  self.local_space_url = _normalize_local_space_url(
105
  os.getenv("INFERENCE_LOCAL_SPACE_URL", "http://127.0.0.1:7860")
106
  )
107
  self.local_generate_path = os.getenv("INFERENCE_LOCAL_SPACE_GENERATE_PATH", "/gradio_api/call/generate")
108
+ self.pro_route_header_name = os.getenv("INFERENCE_PRO_ROUTE_HEADER_NAME", "")
109
+ self.pro_route_header_value = os.getenv("INFERENCE_PRO_ROUTE_HEADER_VALUE", "true")
110
 
111
+ self.enforce_qwen_only = os.getenv("INFERENCE_ENFORCE_QWEN_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
112
+ self.qwen_lock_model = os.getenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen3-32B").strip() or "Qwen/Qwen3-32B"
113
 
114
+ default_model_fallback = str(primary.get("id") or "Qwen/Qwen3-32B")
115
  env_model_id = os.getenv("INFERENCE_MODEL_ID", "").strip()
116
  self.default_model = env_model_id or default_model_fallback
117
+
118
  default_max_tokens = str(primary.get("max_new_tokens") or 512)
119
  self.default_max_new_tokens = int(os.getenv("INFERENCE_MAX_NEW_TOKENS", default_max_tokens))
120
+
121
  default_temp = str(primary.get("temperature") or 0.2)
122
  self.default_temperature = float(os.getenv("INFERENCE_TEMPERATURE", default_temp))
123
+
124
  default_top_p = str(primary.get("top_p") or 0.9)
125
  self.default_top_p = float(os.getenv("INFERENCE_TOP_P", default_top_p))
126
+
127
+ # Task-specific model overrides via environment variables
128
  self.chat_model_override = os.getenv("INFERENCE_CHAT_MODEL_ID", "").strip()
129
  self.chat_model_temp_override = os.getenv(TEMP_CHAT_MODEL_OVERRIDE_ENV, "").strip()
130
  self.chat_strict_model_only = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
131
+ self.chat_hard_model = os.getenv("INFERENCE_CHAT_HARD_MODEL_ID", "meta-llama/Meta-Llama-3-70B-Instruct").strip()
132
+ self.chat_hard_trigger_enabled = os.getenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
133
+ self.chat_hard_prompt_chars = max(256, int(os.getenv("INFERENCE_CHAT_HARD_PROMPT_CHARS", "800")))
134
+ self.chat_hard_history_chars = max(
135
+ self.chat_hard_prompt_chars,
136
+ int(os.getenv("INFERENCE_CHAT_HARD_HISTORY_CHARS", "1800")),
137
+ )
138
+ hard_keywords_raw = os.getenv(
139
+ "INFERENCE_CHAT_HARD_KEYWORDS",
140
+ "step-by-step,show all steps,derive,proof,prove,rigorous,multi-step,word problem",
141
+ )
142
+ self.chat_hard_keywords = [kw.strip().lower() for kw in hard_keywords_raw.split(",") if kw.strip()]
143
 
144
+ self.hf_timeout_sec = int(os.getenv("INFERENCE_HF_TIMEOUT_SEC", "90"))
145
  self.local_timeout_sec = int(os.getenv("INFERENCE_LOCAL_SPACE_TIMEOUT_SEC", "90"))
146
  self.max_retries = int(os.getenv("INFERENCE_MAX_RETRIES", "3"))
147
  self.backoff_sec = float(os.getenv("INFERENCE_BACKOFF_SEC", "1.5"))
148
+ self.interactive_timeout_sec = int(os.getenv("INFERENCE_INTERACTIVE_TIMEOUT_SEC", str(self.hf_timeout_sec)))
149
+ self.background_timeout_sec = int(os.getenv("INFERENCE_BACKGROUND_TIMEOUT_SEC", str(self.hf_timeout_sec)))
150
  self.interactive_max_retries = int(os.getenv("INFERENCE_INTERACTIVE_MAX_RETRIES", str(self.max_retries)))
151
  self.background_max_retries = int(os.getenv("INFERENCE_BACKGROUND_MAX_RETRIES", str(self.max_retries)))
152
  self.interactive_backoff_sec = float(os.getenv("INFERENCE_INTERACTIVE_BACKOFF_SEC", str(self.backoff_sec)))
 
167
  )
168
  self.cpu_only_tasks = {v.strip().lower() for v in cpu_tasks_raw.split(",") if v.strip()}
169
 
170
+ pro_tasks_raw = os.getenv(
171
+ "INFERENCE_PRO_PRIORITY_TASKS",
172
+ "chat,quiz_generation,lesson_generation,learning_path,verify_solution",
173
+ )
174
+ self.pro_priority_tasks = {v.strip().lower() for v in pro_tasks_raw.split(",") if v.strip()}
175
+
176
  interactive_tasks_raw = os.getenv(
177
  "INFERENCE_INTERACTIVE_TASKS",
178
  "chat,verify_solution,daily_insight",
 
184
  )
185
 
186
  # Default task-to-model routing.
187
+ # Keep all tasks pinned to Qwen3-32B when qwen-only lock is active.
188
  self.task_model_map: Dict[str, str] = {
189
+ "chat": "Qwen/Qwen3-32B",
190
+ "verify_solution": "Qwen/Qwen3-32B",
191
+ "lesson_generation": "Qwen/Qwen3-32B",
192
+ "quiz_generation": "Qwen/Qwen3-32B",
193
+ "learning_path": "Qwen/Qwen3-32B",
194
+ "daily_insight": "Qwen/Qwen3-32B",
195
+ "risk_classification": "Qwen/Qwen3-32B",
196
+ "risk_narrative": "Qwen/Qwen3-32B",
197
  }
198
+ # Fallback chains (only to other HF-supported models, no featherless-ai)
199
  self.task_fallback_model_map: Dict[str, List[str]] = {
200
+ "chat": [
201
+ "meta-llama/Llama-3.1-8B-Instruct",
202
+ "google/gemma-2-2b-it",
203
+ ],
204
+ "verify_solution": [
205
+ "meta-llama/Llama-3.1-8B-Instruct",
206
+ "google/gemma-2-2b-it",
207
+ ],
208
  }
209
+ # Model-to-provider mappings (not needed when using model:provider syntax directly)
210
  self.model_provider_map: Dict[str, str] = {}
211
  self.task_provider_map: Dict[str, str] = {}
212
  if isinstance(config, dict):
 
219
  for task, model in task_models.items()
220
  if str(task).strip() and str(model).strip()
221
  }
222
+ # Merge config models with defaults (config overrides defaults)
223
  self.task_model_map.update(config_task_models)
224
  task_fallback_models = routing_cfg.get("task_fallback_model_map", {})
225
  if isinstance(task_fallback_models, dict):
 
251
  for task_key in list(self.task_model_map.keys()):
252
  self.task_model_map[task_key] = env_model_id
253
  LOGGER.info(
254
+ f"🔄 INFERENCE_MODEL_ID env var override applied: {env_model_id}"
255
  )
256
  LOGGER.info(
257
  f" Task model mappings changed from: {original_map}"
 
260
  else:
261
  env_override_note = ""
262
 
263
+ if self.enforce_qwen_only:
264
+ qwen_map_before = dict(self.task_model_map)
265
+ self.default_model = self.qwen_lock_model
266
  for task_key in list(self.task_model_map.keys()):
267
+ self.task_model_map[task_key] = self.qwen_lock_model
268
  self.fallback_models = []
269
  self.task_fallback_model_map = {
270
  task_key: [] for task_key in self.task_model_map.keys()
271
  }
272
+ self.chat_hard_trigger_enabled = False
273
+ LOGGER.info(f"🔒 INFERENCE_ENFORCE_QWEN_ONLY enabled: locking all inference tasks to {self.qwen_lock_model}")
274
+ LOGGER.info(f" Cleared fallback models and hard-escalation path")
275
+ LOGGER.info(f" Task model mappings forced from: {qwen_map_before}")
276
 
277
+ # Log configuration loaded for debugging
278
  config_status = "from file" if config_path else "hardcoded defaults (no config file found)"
279
  effective_chat_model_for_logs = self.chat_model_override or self.task_model_map.get("chat", self.default_model)
280
+ LOGGER.info(f" InferenceClient initialized {config_status}{env_override_note}")
281
  LOGGER.info(f" Default model: {self.default_model}")
282
  LOGGER.info(f" Chat model: {effective_chat_model_for_logs}")
283
  LOGGER.info(f" Chat temp override ({TEMP_CHAT_MODEL_OVERRIDE_ENV}): {self.chat_model_temp_override or 'disabled'}")
284
  LOGGER.info(f" Chat strict model lock: {self.chat_strict_model_only}")
285
+ LOGGER.info(f" Global Qwen-only lock: {self.enforce_qwen_only}")
286
  LOGGER.info(f" Verify solution model: {self.task_model_map.get('verify_solution', self.default_model)}")
287
  LOGGER.info(f" Full task_model_map: {self.task_model_map}")
288
 
 
294
  "requests_error": 0,
295
  "retries_total": 0,
296
  "fallback_attempts": 0,
 
 
297
  "route_counts": {},
298
  "task_counts": {},
299
  "provider_counts": {},
300
  "status_code_counts": {},
301
  }
302
 
 
 
303
  def _bump_metric(self, key: str, inc: int = 1) -> None:
304
  with self._metrics_lock:
305
  current = self._metrics.get(key) or 0
306
  if not isinstance(current, int):
307
  current = 0
308
  self._metrics[key] = current + inc
 
309
 
310
  def _bump_bucket(self, key: str, bucket: str, inc: int = 1) -> None:
311
  with self._metrics_lock:
 
317
  if not isinstance(current, int):
318
  current = 0
319
  mapping[bucket] = current + inc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  def _record_attempt(self, *, task_type: str, provider: str, route: str, fallback_depth: int) -> None:
322
  self._bump_metric("requests_total", 1)
 
328
 
329
  def snapshot_metrics(self) -> Dict[str, Any]:
330
  with self._metrics_lock:
 
 
 
 
331
  snapshot = {
332
  "uptime_sec": round(max(0.0, time.time() - self._metrics_started_at), 2),
333
  "requests_total": self._metrics.get("requests_total") or 0,
 
335
  "requests_error": self._metrics.get("requests_error") or 0,
336
  "retries_total": self._metrics.get("retries_total") or 0,
337
  "fallback_attempts": self._metrics.get("fallback_attempts") or 0,
 
 
 
338
  "route_counts": dict(self._metrics.get("route_counts") or {}),
339
  "task_counts": dict(self._metrics.get("task_counts") or {}),
340
  "provider_counts": dict(self._metrics.get("provider_counts") or {}),
 
346
  effective_task = (req.task_type or "default").strip().lower()
347
  request_tag = req.request_tag.strip() or f"{effective_task}-{int(time.time() * 1000)}"
348
  selected_model, model_selection_source = self._resolve_primary_model(req)
349
+
350
  model_chain = self._model_chain_for_task(effective_task, selected_model)
351
  last_error: Optional[Exception] = None
352
+ provider_chain = self._provider_chain_for_task(req.task_type)
353
+
354
+ # Normalize model name (remove any provider suffix since we use hf_inference router)
355
+ model_base = selected_model.split(":")[0] if ":" in selected_model else selected_model
356
+
357
+ # Log model selection for debugging - confirm which model will actually be used
358
  LOGGER.info(
359
+ f"🎯 request_tag={request_tag} task={effective_task} source={model_selection_source} "
360
+ f"selected_model={model_base} (primary) provider_chain={provider_chain}"
361
  )
362
  LOGGER.info(f" fallback_chain={model_chain[1:] if len(model_chain) > 1 else 'none'}")
363
 
364
+
365
  for fallback_depth, model_name in enumerate(model_chain):
366
  request_for_model = InferenceRequest(
367
  messages=req.messages,
 
374
  repetition_penalty=req.repetition_penalty,
375
  timeout_sec=req.timeout_sec,
376
  )
377
+
378
+ for provider in provider_chain:
379
+ try:
380
+ result = self._generate_with_provider(request_for_model, provider, fallback_depth)
381
+ if fallback_depth > 0:
382
+ LOGGER.info(f"✅ Fallback succeeded at depth={fallback_depth} model={model_name} provider={provider}")
383
+ return result
384
+ except Exception as exc:
385
+ last_error = exc
386
+ fallback_hint = f" (depth {fallback_depth})" if fallback_depth > 0 else ""
387
+ LOGGER.warning(
388
+ f"⚠️ Attempt failed{fallback_hint}: task={request_for_model.task_type} "
389
+ f"provider={provider} model={model_name} error={exc.__class__.__name__}: {str(exc)[:100]}"
390
+ )
391
 
392
  if last_error:
393
  raise last_error
 
400
  effective_task = (req.task_type or "default").strip().lower()
401
  runtime_chat_override = self._runtime_chat_model_override()
402
 
403
+ def _base_model(model_name: str) -> str:
404
+ return (model_name or "").split(":", 1)[0].strip()
405
+
406
+ # Check explicit request model first, then chat override env, then task map/default.
407
  if effective_task == "chat" and runtime_chat_override:
408
  selected_model = runtime_chat_override
409
  model_selection_source = "chat_temp_override_env"
 
417
  selected_model = self.task_model_map.get(effective_task, self.default_model)
418
  model_selection_source = "task_map"
419
 
420
+ if self.enforce_qwen_only:
421
+ effective_qwen_lock_model = self.qwen_lock_model
422
  if effective_task == "chat":
423
+ effective_qwen_lock_model = runtime_chat_override or self.chat_model_override or self.qwen_lock_model
424
 
425
+ selected_base = _base_model(selected_model)
426
+ lock_base = _base_model(effective_qwen_lock_model)
427
  if selected_base != lock_base:
428
  LOGGER.warning(
429
+ f"⚠️ Qwen-only lock replaced requested model {selected_model} with {effective_qwen_lock_model}"
430
  )
431
+ selected_model = effective_qwen_lock_model
432
+ model_selection_source = f"{model_selection_source}:qwen_only"
433
 
434
  if effective_task == "chat" and self.chat_strict_model_only:
435
  return selected_model, f"{model_selection_source}:chat_strict_model_only"
436
 
437
+ if effective_task == "chat" and self.chat_hard_trigger_enabled and self.chat_hard_model:
438
+ should_escalate, reason = self._should_escalate_chat_to_hard_model(req.messages)
439
+ if should_escalate and selected_model != self.chat_hard_model:
440
+ return self.chat_hard_model, f"chat_hard_escalation:{reason}"
441
+
442
  return selected_model, model_selection_source
443
 
444
+ def _should_escalate_chat_to_hard_model(self, messages: List[Dict[str, str]]) -> Tuple[bool, str]:
445
+ latest_user = self._latest_user_message(messages)
446
+ if not latest_user:
447
+ return False, "no_user_message"
448
+
449
+ latest_norm = latest_user.lower()
450
+ prompt_chars = len(latest_user)
451
+ history_chars = 0
452
+ for msg in messages:
453
+ content = (msg.get("content") or "") if isinstance(msg, dict) else ""
454
+ history_chars += len(content)
455
+
456
+ keyword_hit = ""
457
+ for kw in self.chat_hard_keywords:
458
+ if kw and kw in latest_norm:
459
+ keyword_hit = kw
460
+ break
461
+
462
+ math_marker_count = len(
463
+ re.findall(
464
+ r"(=|\bintegral\b|\bderivative\b|\bmatrix\b|\blimit\b|\bproof\b|\bderive\b|\bsolve\b)",
465
+ latest_norm,
466
+ )
467
+ )
468
+
469
+ long_prompt = prompt_chars >= self.chat_hard_prompt_chars
470
+ long_history = history_chars >= self.chat_hard_history_chars
471
+ immediate_hard_request = any(
472
+ phrase in latest_norm
473
+ for phrase in (
474
+ "show all steps",
475
+ "step-by-step",
476
+ "step by step",
477
+ "rigorous proof",
478
+ "formal proof",
479
+ )
480
+ )
481
+
482
+ # Escalate immediately for long step-by-step prompts or heavy math density.
483
+ escalate = bool(keyword_hit and immediate_hard_request)
484
+ if not escalate:
485
+ escalate = bool(keyword_hit and (long_prompt or long_history or math_marker_count >= 2))
486
+ if not escalate and long_prompt and math_marker_count >= 2:
487
+ escalate = True
488
+ if not escalate and long_history and math_marker_count >= 2:
489
+ escalate = True
490
+
491
+ if not escalate:
492
+ return False, "normal"
493
+
494
+ reasons: List[str] = []
495
+ if long_prompt:
496
+ reasons.append(f"prompt_chars={prompt_chars}")
497
+ if long_history:
498
+ reasons.append(f"history_chars={history_chars}")
499
+ if keyword_hit:
500
+ reasons.append(f"keyword={keyword_hit}")
501
+ if immediate_hard_request:
502
+ reasons.append("immediate_hard_request")
503
+ if math_marker_count >= 2:
504
+ reasons.append(f"math_markers={math_marker_count}")
505
+ return True, ",".join(reasons) if reasons else "hard_prompt"
506
+
507
  def _model_chain_for_task(self, task_type: str, selected_model: str) -> List[str]:
508
  normalized = (task_type or "default").strip().lower()
509
  runtime_chat_override = self._runtime_chat_model_override() if normalized == "chat" else ""
510
+ chat_qwen_lock_model = runtime_chat_override or (self.chat_model_override if normalized == "chat" else "")
511
 
512
+ if self.enforce_qwen_only:
513
  if normalized == "chat":
514
+ locked_model = (chat_qwen_lock_model or self.qwen_lock_model or "").strip()
515
  else:
516
+ locked_model = (self.qwen_lock_model or "").strip()
517
  return [locked_model] if locked_model else []
518
 
519
  if normalized == "chat" and self.chat_strict_model_only:
520
+ chat_model = (chat_qwen_lock_model or selected_model or "").strip()
521
  return [chat_model] if chat_model else []
522
 
523
  per_task_candidates = self.task_fallback_model_map.get(task_type, [])
 
537
  return deduped[:max_models]
538
  return deduped
539
 
540
+ def _provider_chain_for_task(self, task_type: str) -> List[str]:
541
+ normalized = (task_type or "default").strip().lower()
542
+ forced_provider = self.task_provider_map.get(normalized)
543
+ if forced_provider:
544
+ return [forced_provider]
545
+
546
+ if normalized in self.cpu_only_tasks:
547
+ return [self.cpu_provider]
548
+
549
+ if self.pro_enabled and normalized in self.pro_priority_tasks:
550
+ chain = [self.pro_provider]
551
+ if self.enable_provider_fallback and self.gpu_provider not in chain:
552
+ chain.append(self.gpu_provider)
553
+ if self.enable_provider_fallback and self.provider not in chain:
554
+ chain.append(self.provider)
555
+ return chain
556
+
557
+ if normalized in self.gpu_required_tasks:
558
+ chain = [self.gpu_provider]
559
+ if self.enable_provider_fallback and self.cpu_provider != self.gpu_provider:
560
+ chain.append(self.cpu_provider)
561
+ return chain
562
+
563
+ chain = [self.provider]
564
+ if self.enable_provider_fallback and self.cpu_provider not in chain:
565
+ chain.append(self.cpu_provider)
566
+ return chain
567
+
568
  def _retry_profile(self, task_type: str) -> Tuple[int, float]:
569
  normalized = (task_type or "default").strip().lower()
570
  if normalized in self.interactive_tasks:
 
581
  return self.interactive_timeout_sec
582
  return self.background_timeout_sec
583
 
584
+ def _resolve_route_label(self, provider: str, task_type: str) -> str:
585
+ normalized = (task_type or "default").strip().lower()
586
+ if self.pro_enabled and normalized in self.pro_priority_tasks and provider == self.pro_provider:
587
+ return "pro-priority"
588
+ return "standard"
589
+
590
+ def _generate_with_provider(self, req: InferenceRequest, provider: str, fallback_depth: int) -> str:
591
+ route = self._resolve_route_label(provider, req.task_type)
592
+ if provider == "local_space":
593
+ return self._call_local_space(req, provider=provider, route=route, fallback_depth=fallback_depth)
594
+
595
+ # All models use HF inference router directly (including Qwen/Qwen3-32B)
596
+ return self._call_hf_inference(req, provider=provider, route=route, fallback_depth=fallback_depth)
597
+
598
  def _messages_to_prompt(self, messages: List[Dict[str, str]]) -> str:
599
  parts: List[str] = []
600
  for msg in messages:
 
607
  prefix = "SYSTEM"
608
  elif role == "assistant":
609
  prefix = "ASSISTANT"
610
+ parts.append(f"{prefix}:\\n{content}")
611
  parts.append("ASSISTANT:")
612
+ return "\\n\\n".join(parts)
613
 
614
  def _latest_user_message(self, messages: List[Dict[str, str]]) -> str:
615
  for msg in reversed(messages):
 
619
  return content
620
  return self._messages_to_prompt(messages)
621
 
622
+ def _post_with_retry(
623
+ self,
624
+ url: str,
625
+ *,
626
+ headers: Dict[str, str],
627
+ payload: Dict[str, object],
628
+ timeout: int,
629
+ provider: str,
630
+ model: str,
631
+ task_type: str,
632
+ request_tag: str,
633
+ fallback_depth: int,
634
+ route: str,
635
+ ) -> Tuple[requests.Response, float, int]:
636
+ self._record_attempt(
637
+ task_type=task_type,
638
+ provider=provider,
639
+ route=route,
640
+ fallback_depth=fallback_depth,
641
  )
 
 
642
  max_retries, backoff_sec = self._retry_profile(task_type)
643
+ attempt = 0
644
 
645
+ def _retry_sleep(retry_attempt: int) -> None:
646
+ # Small jitter reduces synchronized retry storms during transient provider issues.
647
+ jitter_factor = random.uniform(0.9, 1.2)
648
+ time.sleep(backoff_sec * retry_attempt * jitter_factor)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
 
650
+ while True:
 
 
 
 
 
 
651
  start = time.perf_counter()
652
  try:
653
+ resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
654
+ except Exception as exc:
655
  latency_ms = (time.perf_counter() - start) * 1000
 
 
 
 
 
 
 
 
656
  log_model_call(
657
  LOGGER,
658
+ provider=provider,
659
+ model=model,
660
+ endpoint=url,
661
  latency_ms=latency_ms,
662
  input_tokens=None,
663
  output_tokens=None,
664
+ status="error",
665
+ error_class=exc.__class__.__name__,
666
+ error_message=str(exc),
667
  task_type=task_type,
668
+ request_tag=request_tag,
669
  retry_attempt=attempt + 1,
670
  fallback_depth=fallback_depth,
671
  route=route,
672
  )
673
+ if attempt >= max_retries - 1:
674
+ self._bump_metric("requests_error", 1)
675
+ raise
676
+ attempt += 1
677
+ self._bump_metric("retries_total", 1)
678
+ _retry_sleep(attempt)
679
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
 
681
+ latency_ms = (time.perf_counter() - start) * 1000
682
+ if resp.status_code in {408, 429, 500, 502, 503, 504} and attempt < max_retries - 1:
 
683
  log_model_call(
684
  LOGGER,
685
+ provider=provider,
686
+ model=model,
687
+ endpoint=url,
688
  latency_ms=latency_ms,
689
  input_tokens=None,
690
  output_tokens=None,
691
  status="error",
692
+ error_class="HTTPRetry",
693
+ error_message=f"status={resp.status_code}",
694
  task_type=task_type,
695
+ request_tag=request_tag,
696
  retry_attempt=attempt + 1,
697
  fallback_depth=fallback_depth,
698
  route=route,
699
  )
700
+ attempt += 1
701
+ self._bump_metric("retries_total", 1)
702
+ _retry_sleep(attempt)
703
+ continue
704
+ return resp, latency_ms, attempt + 1
705
 
706
+ def _call_hf_inference_direct(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
707
+ """
708
+ Call Qwen models via Featherless AI provider.
709
+ Uses HF InferenceClient with provider="featherless-ai" for direct model access.
710
+ """
711
+ if not self.hf_token:
712
+ raise RuntimeError("HF_TOKEN is not set")
713
 
 
714
  target_model = req.model or self.default_model
715
+ target_model_base = target_model.split(":")[0] if ":" in target_model else target_model
716
+
 
 
 
 
 
 
 
 
 
 
 
 
717
  timeout = self._timeout_for(req, provider)
 
 
 
 
 
 
 
718
  start = time.perf_counter()
719
+
720
  try:
721
+ # Use HF InferenceClient with featherless-ai provider for Qwen models.
722
+ client = HFInferenceClient(
723
+ model=target_model_base,
724
+ token=self.hf_token,
725
+ provider="featherless-ai",
726
+ timeout=timeout
727
+ )
728
+
729
+ response = client.chat_completion(
730
+ messages=req.messages,
731
+ max_tokens=req.max_new_tokens or self.default_max_new_tokens,
732
+ temperature=req.temperature or self.default_temperature,
733
+ top_p=req.top_p or self.default_top_p,
734
+ )
735
+ latency_ms = (time.perf_counter() - start) * 1000
736
+
737
+ # Extract text from response
738
+ if hasattr(response, "choices") and response.choices:
739
+ content = response.choices[0].message.content or ""
740
+ text = content.strip()
741
+ else:
742
+ text = self._extract_text(response)
743
+
744
+ log_model_call(
745
+ LOGGER,
746
+ provider="featherless-ai",
747
+ model=target_model_base,
748
+ endpoint="featherless-ai_inference",
749
+ latency_ms=latency_ms,
750
+ input_tokens=None,
751
+ output_tokens=None,
752
+ status="ok",
753
+ task_type=req.task_type,
754
+ request_tag=req.request_tag,
755
+ retry_attempt=1,
756
+ fallback_depth=fallback_depth,
757
+ route=route,
758
+ )
759
+ self._record_attempt(
760
+ task_type=req.task_type,
761
+ provider="featherless-ai",
762
+ route=route,
763
+ fallback_depth=fallback_depth,
764
+ )
765
+ self._bump_metric("requests_ok", 1)
766
+ return text
767
+
768
  except Exception as exc:
769
  latency_ms = (time.perf_counter() - start) * 1000
770
+ self._bump_metric("requests_error", 1)
771
  log_model_call(
772
  LOGGER,
773
+ provider="featherless-ai",
774
+ model=target_model_base,
775
+ endpoint="featherless-ai_inference",
776
  latency_ms=latency_ms,
777
  input_tokens=None,
778
  output_tokens=None,
 
785
  fallback_depth=fallback_depth,
786
  route=route,
787
  )
788
+ LOGGER.warning(
789
+ "task=%s provider=featherless-ai model=%s fallback_depth=%s failed: %s",
790
+ req.task_type,
791
+ target_model_base,
792
+ fallback_depth,
793
+ exc,
794
+ )
795
  raise
796
 
797
+ def _call_hf_inference(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
798
+ if not self.hf_token:
799
+ raise RuntimeError("HF_TOKEN is not set")
800
+
801
+ target_model = req.model or self.default_model
802
+ chat_model = target_model if ":" in target_model else f"{target_model}:fastest"
803
+ url = self.hf_chat_url
804
+
805
+ # Log which model is actually being used
806
+ model_base = target_model.split(":")[0] if ":" in target_model else target_model
807
+ LOGGER.debug(
808
+ f"📌 Calling HF inference: task={req.task_type} model={model_base} "
809
+ f"route={route} depth={fallback_depth}"
810
+ )
811
+
812
+ payload: Dict[str, object] = {
813
+ "model": chat_model,
814
+ "messages": req.messages,
815
+ "stream": False,
816
+ "max_tokens": req.max_new_tokens or self.default_max_new_tokens,
817
+ "temperature": req.temperature,
818
+ "top_p": req.top_p,
819
+ }
820
+ headers = {
821
+ "Authorization": f"Bearer {self.hf_token}",
822
+ "Content-Type": "application/json",
823
+ "X-MathPulse-Task": (req.task_type or "default").strip().lower(),
824
+ }
825
+ if route == "pro-priority" and self.pro_route_header_name.strip():
826
+ headers[self.pro_route_header_name.strip()] = self.pro_route_header_value
827
+
828
+ timeout = self._timeout_for(req, provider)
829
+
830
+ resp, latency_ms, retry_attempt = self._post_with_retry(
831
+ url,
832
+ headers=headers,
833
+ payload=payload,
834
+ timeout=timeout,
835
+ provider=provider,
836
+ model=target_model,
837
+ task_type=req.task_type,
838
+ request_tag=req.request_tag,
839
+ fallback_depth=fallback_depth,
840
+ route=route,
841
+ )
842
+ self._bump_bucket("status_code_counts", str(resp.status_code), 1)
843
+ if resp.status_code != 200:
844
+ self._bump_metric("requests_error", 1)
845
+ raise RuntimeError(f"HF Inference error {resp.status_code}: {resp.text}")
846
+
847
+ data = resp.json()
848
+ text = self._extract_text(data)
849
+
850
+ # Log successful inference with actual model and response time
851
+ LOGGER.info(
852
+ f"✅ HF inference success: task={req.task_type} model={model_base} "
853
+ f"latency={latency_ms:.0f}ms tokens_out={len(text.split())}"
854
+ )
855
+
856
+ log_model_call(
857
+ LOGGER,
858
+ provider=provider,
859
+ model=target_model,
860
+ endpoint=url,
861
+ latency_ms=latency_ms,
862
+ input_tokens=None,
863
+ output_tokens=None,
864
+ status="ok",
865
+ task_type=req.task_type,
866
+ request_tag=req.request_tag,
867
+ retry_attempt=retry_attempt,
868
+ fallback_depth=fallback_depth,
869
+ route=route,
870
+ )
871
+ self._bump_metric("requests_ok", 1)
872
+ return text
873
+
874
+ def _call_featherless(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
875
+ if not self.featherless_api_key:
876
+ raise RuntimeError("FEATHERLESS_API_KEY is not set")
877
+
878
+ target_model = req.model or self.default_model
879
+ url = self.featherless_chat_url
880
+
881
+ payload: Dict[str, object] = {
882
+ "model": target_model,
883
+ "messages": req.messages,
884
+ "stream": False,
885
+ "max_tokens": req.max_new_tokens or self.default_max_new_tokens,
886
+ "temperature": req.temperature,
887
+ "top_p": req.top_p,
888
+ }
889
+ headers = {
890
+ "Authorization": f"Bearer {self.featherless_api_key}",
891
+ "Content-Type": "application/json",
892
+ "X-MathPulse-Task": (req.task_type or "default").strip().lower(),
893
+ }
894
+
895
+ timeout = self._timeout_for(req, provider)
896
+
897
+ resp, latency_ms, retry_attempt = self._post_with_retry(
898
+ url,
899
+ headers=headers,
900
+ payload=payload,
901
+ timeout=timeout,
902
+ provider=provider,
903
+ model=target_model,
904
+ task_type=req.task_type,
905
+ request_tag=req.request_tag,
906
+ fallback_depth=fallback_depth,
907
+ route=route,
908
+ )
909
+ self._bump_bucket("status_code_counts", str(resp.status_code), 1)
910
+ if resp.status_code != 200:
911
+ self._bump_metric("requests_error", 1)
912
+ raise RuntimeError(f"Featherless API error {resp.status_code}: {resp.text}")
913
+
914
+ data = resp.json()
915
+ text = self._extract_text(data)
916
+ log_model_call(
917
+ LOGGER,
918
+ provider=provider,
919
+ model=target_model,
920
+ endpoint=url,
921
+ latency_ms=latency_ms,
922
+ input_tokens=None,
923
+ output_tokens=None,
924
+ status="ok",
925
+ task_type=req.task_type,
926
+ request_tag=req.request_tag,
927
+ retry_attempt=retry_attempt,
928
+ fallback_depth=fallback_depth,
929
+ route=route,
930
+ )
931
+ self._bump_metric("requests_ok", 1)
932
+ return text
933
+
934
+ def _call_local_space(self, req: InferenceRequest, *, provider: str, route: str, fallback_depth: int) -> str:
935
+ target_model = req.model or self.default_model
936
+ url = f"{self.local_space_url.rstrip('/')}{self.local_generate_path}"
937
+
938
+ prompt = self._messages_to_prompt(req.messages)
939
+ payload: Dict[str, object] = {
940
+ "data": [
941
+ prompt,
942
+ [],
943
+ req.temperature,
944
+ req.top_p,
945
+ req.max_new_tokens,
946
+ ]
947
+ }
948
+ headers = {"Content-Type": "application/json"}
949
+
950
+ timeout = self._timeout_for(req, provider)
951
+
952
+ resp, latency_ms, retry_attempt = self._post_with_retry(
953
+ url,
954
+ headers=headers,
955
+ payload=payload,
956
+ timeout=timeout,
957
+ provider=provider,
958
+ model=target_model,
959
+ task_type=req.task_type,
960
+ request_tag=req.request_tag,
961
+ fallback_depth=fallback_depth,
962
+ route=route,
963
+ )
964
  self._bump_bucket("status_code_counts", str(resp.status_code), 1)
965
 
966
  if resp.status_code != 200:
 
999
  status="ok",
1000
  task_type=req.task_type,
1001
  request_tag=req.request_tag,
1002
+ retry_attempt=retry_attempt,
1003
  fallback_depth=fallback_depth,
1004
  route=route,
1005
  )
 
1040
 
1041
  def _clean_response_text(self, text: str) -> str:
1042
  """Strip JSON braces, template artifacts, and whitespace from response text."""
1043
+ # Strip leading/trailing whitespace
1044
  text = text.strip()
1045
+
1046
+ # Remove wrapping JSON braces or artifact markers
1047
  if text.startswith("{") and text.endswith("}"):
1048
  try:
1049
+ # Try to parse as JSON - if it fails, return as-is
1050
  parsed = json.loads(text)
1051
+ # If it's a dict with a "content" or "text" field, use that
1052
  if isinstance(parsed, dict):
1053
  if "content" in parsed:
1054
  text = str(parsed["content"]).strip()
1055
  elif "text" in parsed:
1056
  text = str(parsed["text"]).strip()
1057
  except json.JSONDecodeError:
1058
+ # Not valid JSON, just clean up braces
1059
  text = text.strip("{}")
1060
+
1061
+ # Remove any trailing artifact markers
1062
  if text.startswith("```json") or text.startswith("```"):
1063
  text = re.sub(r"^```(?:json)?", "", text).strip()
1064
  if text.endswith("```"):
1065
  text = text[:-3].strip()
1066
+
1067
  return text.strip()
1068
 
1069
 
1070
+ def create_default_client() -> InferenceClient:
1071
+ return InferenceClient()
 
 
 
 
 
 
 
 
 
 
 
 
 
services/question_bank_service.py DELETED
@@ -1,123 +0,0 @@
1
- """
2
- Question Bank Service for Quiz Battle.
3
-
4
- Handles querying the question bank with random ordering,
5
- caching session questions, and 24-hour debounce for variance results.
6
- """
7
-
8
- import os
9
- import random
10
- from datetime import datetime, timezone, timedelta
11
- from typing import List, Dict, Optional
12
-
13
- from google.cloud import firestore
14
-
15
- DEFAULT_FIREBASE_PROJECT = os.getenv("FIREBASE_AUTH_PROJECT_ID", "mathpulse-ai-2026")
16
-
17
-
18
- def _get_db() -> firestore.Client:
19
- """Get Firestore client."""
20
- return firestore.Client(project=DEFAULT_FIREBASE_PROJECT)
21
-
22
-
23
- async def get_questions_for_battle(
24
- grade_level: int,
25
- topic: str,
26
- count: int = 10,
27
- ) -> List[Dict]:
28
- """
29
- Fetch random questions from the question bank for a battle session.
30
-
31
- Uses Firestore random_seed field for pseudo-random ordering.
32
- If fewer than `count` questions exist, returns all available.
33
- """
34
- db = _get_db()
35
- collection_path = f"question_bank/{grade_level}/{topic}/questions"
36
- collection_ref = db.collection(collection_path)
37
-
38
- # Pseudo-random query using random_seed >= random threshold
39
- threshold = random.random()
40
- query = (
41
- collection_ref
42
- .where("random_seed", ">=", threshold)
43
- .order_by("random_seed")
44
- .limit(count)
45
- )
46
- docs = list(query.stream())
47
-
48
- # If we didn't get enough, query from the start to fill shortfall
49
- if len(docs) < count:
50
- remaining = count - len(docs)
51
- fallback_query = (
52
- collection_ref
53
- .where("random_seed", "<", threshold)
54
- .order_by("random_seed")
55
- .limit(remaining)
56
- )
57
- docs.extend(list(fallback_query.stream()))
58
-
59
- questions = [doc.to_dict() for doc in docs]
60
- # Ensure all required fields are present
61
- valid_questions = []
62
- for q in questions:
63
- if q and all(k in q for k in ("question", "choices", "correct_answer", "difficulty")):
64
- valid_questions.append(q)
65
-
66
- return valid_questions
67
-
68
-
69
- async def cache_session_questions(
70
- session_id: str,
71
- questions: List[Dict],
72
- player_ids: List[str],
73
- grade_level: int,
74
- topic: str,
75
- ) -> None:
76
- """Cache varied questions for a battle session with 24-hour TTL."""
77
- db = _get_db()
78
- session_ref = db.collection("quiz_battle_sessions").document(session_id)
79
-
80
- session_ref.set({
81
- "player_ids": player_ids,
82
- "grade_level": grade_level,
83
- "topic": topic,
84
- "created_at": firestore.SERVER_TIMESTAMP,
85
- "variance_cached_until": datetime.now(timezone.utc) + timedelta(hours=24),
86
- })
87
-
88
- # Write questions to subcollection
89
- batch = db.batch()
90
- for idx, q in enumerate(questions):
91
- q_ref = session_ref.collection("questions").document(str(idx))
92
- batch.set(q_ref, q)
93
- batch.commit()
94
-
95
-
96
- async def get_cached_session(session_id: str) -> Optional[List[Dict]]:
97
- """
98
- Check if a session has cached varied questions within 24 hours.
99
-
100
- Returns the cached questions if valid, otherwise None.
101
- """
102
- db = _get_db()
103
- session_doc = db.collection("quiz_battle_sessions").document(session_id).get()
104
- if not session_doc.exists:
105
- return None
106
-
107
- data = session_doc.to_dict()
108
- cached_until = data.get("variance_cached_until")
109
- if cached_until:
110
- if isinstance(cached_until, datetime):
111
- if cached_until.tzinfo is None:
112
- cached_until = cached_until.replace(tzinfo=timezone.utc)
113
- elif hasattr(cached_until, 'timestamp'):
114
- # Firestore Timestamp object
115
- cached_until = datetime.fromtimestamp(cached_until.timestamp(), tz=timezone.utc)
116
-
117
- if cached_until > datetime.now(timezone.utc):
118
- # Return cached questions
119
- q_docs = db.collection("quiz_battle_sessions").document(session_id).collection("questions").stream()
120
- questions = [doc.to_dict() for doc in q_docs]
121
- return questions if questions else None
122
-
123
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/user_provisioning_service.py CHANGED
@@ -185,6 +185,7 @@ class UserProvisioningService:
185
  "level": 1,
186
  "currentXP": 0,
187
  "totalXP": 0,
 
188
  "atRiskSubjects": [],
189
  "hasTakenDiagnostic": False,
190
  }
 
185
  "level": 1,
186
  "currentXP": 0,
187
  "totalXP": 0,
188
+ "streak": 0,
189
  "atRiskSubjects": [],
190
  "hasTakenDiagnostic": False,
191
  }
services/variance_engine.py DELETED
@@ -1,115 +0,0 @@
1
- """
2
- Variance Engine for Quiz Battle Questions.
3
-
4
- Applies per-session variance techniques via DeepSeek,
5
- with pure-Python fallback for choice shuffling.
6
- """
7
-
8
- import json
9
- import random
10
- import re
11
- from typing import List, Dict
12
-
13
- from services.ai_client import get_deepseek_client, CHAT_MODEL
14
- from services.question_bank_service import get_cached_session, cache_session_questions
15
-
16
-
17
- def _fallback_shuffle(questions: List[Dict], seed: int) -> List[Dict]:
18
- """
19
- Pure-Python fallback: shuffle choices deterministically.
20
- """
21
- rng = random.Random(seed)
22
- for q in questions:
23
- choices = q["choices"].copy()
24
- correct_letter = q["correct_answer"]
25
- correct_index = ord(correct_letter) - ord("A")
26
- correct_text = choices[correct_index]
27
- rng.shuffle(choices)
28
- q["choices"] = choices
29
- q["correct_answer"] = chr(ord("A") + choices.index(correct_text))
30
- q["variance_applied"] = ["choice_shuffle"]
31
- return questions
32
-
33
-
34
- async def apply_variance(questions: List[Dict], session_id: str) -> List[Dict]:
35
- """
36
- Apply per-session variance to a list of questions.
37
-
38
- 1. Check 24h Firestore cache first
39
- 2. Call DeepSeek with variance prompt
40
- 3. Parse JSON response
41
- 4. Fall back to pure-Python shuffle if DeepSeek fails
42
- 5. Cache result for 24 hours
43
- """
44
- # 1. Check cache
45
- cached = await get_cached_session(session_id)
46
- if cached:
47
- return cached
48
-
49
- # 2. Generate deterministic seed from session_id
50
- seed = hash(session_id) % (2**32)
51
-
52
- # 3. Call DeepSeek
53
- client = get_deepseek_client()
54
- system_prompt = (
55
- "You are a math quiz variance engine for MathPulse AI, an educational platform for "
56
- "Filipino high school students following the DepEd K-12 curriculum. "
57
- "Your job is to make quiz questions feel fresh each session WITHOUT changing the "
58
- "correct answer or difficulty level."
59
- )
60
-
61
- user_prompt = f"""Given these {len(questions)} quiz battle questions as JSON:
62
- {json.dumps(questions, indent=2)}
63
-
64
- Apply the following variance techniques. Use session_seed={seed} for deterministic but varied output:
65
-
66
- PARAPHRASE (30% chance per question): Reword the question stem using different phrasing, synonyms, or sentence structure. Do NOT change the math or the answer.
67
-
68
- CHOICE SHUFFLE (always): Randomize the order of answer choices A/B/C/D. Update "correct_answer" to reflect the new position.
69
-
70
- DISTRACTOR REFRESH (20% chance per question): Replace 1-2 wrong choices with new plausible-but-incorrect distractors that represent common student misconceptions for this topic. Keep the correct answer unchanged.
71
-
72
- CONTEXT SWAP (10% chance per question): Replace real-world context variables (names, objects, currencies) with Filipino-localized equivalents (e.g., "pesos", "jeepney", "barangay") to increase cultural relevance.
73
-
74
- NUMERIC SCALING (10% chance, only for computation problems): Scale numbers by a small integer factor (2x or 3x) so the method remains the same but the answer changes. Recompute the correct answer and all distractors accordingly.
75
-
76
- Return the full modified questions array as valid JSON only. Keep all original fields.
77
- Add a "variance_applied": ["paraphrase", "distractor_refresh", ...] field per question.
78
- Do NOT change "topic", "difficulty", "grade_level", or "source_chunk_id"."""
79
-
80
- try:
81
- response = client.chat.completions.create(
82
- model=CHAT_MODEL,
83
- messages=[
84
- {"role": "system", "content": system_prompt},
85
- {"role": "user", "content": user_prompt},
86
- ],
87
- temperature=0.5,
88
- max_tokens=4000,
89
- )
90
- content = response.choices[0].message.content.strip()
91
- # Strip markdown code fences
92
- content = re.sub(r"^```json\s*", "", content)
93
- content = re.sub(r"\s*```$", "", content)
94
- varied_questions = json.loads(content)
95
-
96
- if not isinstance(varied_questions, list) or len(varied_questions) != len(questions):
97
- raise ValueError("Invalid response format from DeepSeek")
98
-
99
- # Validate required fields
100
- for q in varied_questions:
101
- if not all(k in q for k in ("question", "choices", "correct_answer", "variance_applied")):
102
- raise ValueError("Missing required fields in varied question")
103
-
104
- except Exception as e:
105
- print(f"[variance_engine] DeepSeek variance failed, falling back to shuffle: {e}")
106
- varied_questions = _fallback_shuffle(questions, seed)
107
-
108
- # 4. Cache for 24 hours
109
- # Extract player_ids, grade_level, topic from original questions if available
110
- player_ids = []
111
- grade_level = questions[0].get("grade_level", 11) if questions else 11
112
- topic = questions[0].get("topic", "general_mathematics") if questions else "general_mathematics"
113
- await cache_session_questions(session_id, varied_questions, player_ids, grade_level, topic)
114
-
115
- return varied_questions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/youtube_service.py DELETED
@@ -1,1017 +0,0 @@
1
- """
2
- Smart YouTube Video Search Service for MathPulse AI.
3
- Uses YouTube Data API v3 (googleapiclient.discovery) to find relevant
4
- educational math videos, enriched with RAG curriculum context and DeepSeek
5
- query generation for contextual fallback when exact matches don't exist.
6
- Results are cached in Firestore video_cache/{lessonId} with 7-day TTL.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import hashlib
12
- import json
13
- import logging
14
- import os
15
- import re
16
- from datetime import datetime, timezone
17
- from typing import Dict, List, Optional
18
-
19
- logger = logging.getLogger("mathpulse.youtube")
20
-
21
- YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY", "").strip()
22
-
23
- # Known educational channel keywords and exact names for post-filtering
24
- _EDUCATIONAL_CHANNEL_KEYWORDS = [
25
- "khan", "math", "academy", "education", "teacher", "professor",
26
- "tutorial", "lesson", "school", "university", "college", "deped",
27
- "philippines", "filipino", "pinoy", "stem", "learning", "study",
28
- "organic chemistry tutor", "patrickjmt", "3blue1brown", "numberphile",
29
- "math antics", "bright side", "crashcourse", "ted-ed", "ted ed",
30
- "nancy pi", "professor leonard", "mit", "stanford", "harvard",
31
- "mashup math", "mathcoach", "mathologer", "stand-up maths",
32
- "eddie woo", "black pen red pen", "michel van biezen", "brian mclogan",
33
- "mathbff", "krista king", "mathMeeting", "mathbyfives", "yourteacher",
34
- "virtual nerd", "study.com", "coursera", "edx", "brilliant",
35
- "filipino math", "tagalog math", "pinoy teacher", "math philippines",
36
- "shs math", "senior high school math", "grade 11 math", "grade 12 math",
37
- "general mathematics", "business math", "statistics", "probability",
38
- "finite math", "precalculus", "calculus", "algebra", "geometry",
39
- "trigonometry", "functions", "equations", "problem solving",
40
- ]
41
-
42
- _EDUCATIONAL_CHANNEL_EXACT = {
43
- "khan academy", "patrickjmt", "3blue1brown", "numberphile",
44
- "math antics", "the organic chemistry tutor", "professor leonard",
45
- "nancy pi", "ted-ed", "crashcourse", "bright side",
46
- "mit opencourseware", "stanford", "harvard", "mashup math",
47
- "mathcoach", "mathologer", "stand-up maths", "eddie woo",
48
- "black pen red pen", "michel van biezen", "brian mclogan",
49
- "mathbff", "krista king", "mathmeeting", "mathbyfives", "yourteacher",
50
- "virtual nerd", "study.com", "coursera", "brilliant.org",
51
- }
52
-
53
- # Duration filters
54
- _MIN_DURATION_SECONDS = 120 # 2 minutes (allow shorter tutorials)
55
- _MAX_DURATION_SECONDS = 3600 # 60 minutes
56
- _TARGET_MIN_SECONDS = 300 # 5 minutes (ideal)
57
- _TARGET_MAX_SECONDS = 1200 # 20 minutes (ideal)
58
-
59
- # Cache TTL in seconds (7 days)
60
- _CACHE_TTL_SECONDS = 7 * 24 * 60 * 60
61
-
62
- # Guaranteed fallback videos by subject — these are well-known educational videos
63
- # that are extremely likely to exist and be relevant. Used as nuclear option
64
- # when YouTube API returns nothing for all search strategies.
65
- _GUARANTEED_FALLBACK_VIDEOS = {
66
- "default": [
67
- {
68
- "videoId": "p6j8HhfJ5Mc",
69
- "title": "The Essence of Calculus",
70
- "channelTitle": "3Blue1Brown",
71
- "thumbnailUrl": "https://img.youtube.com/vi/p6j8HhfJ5Mc/hqdefault.jpg",
72
- "durationSeconds": 1024,
73
- "description": "A beautiful introduction to calculus concepts.",
74
- },
75
- {
76
- "videoId": "fNk_zzaMoSs",
77
- "title": "Introduction to Algebra",
78
- "channelTitle": "Khan Academy",
79
- "thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
80
- "durationSeconds": 720,
81
- "description": "Fundamentals of algebraic thinking and equations.",
82
- },
83
- ],
84
- "general mathematics": [
85
- {
86
- "videoId": "fNk_zzaMoSs",
87
- "title": "Introduction to Algebra",
88
- "channelTitle": "Khan Academy",
89
- "thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
90
- "durationSeconds": 720,
91
- "description": "Fundamentals of algebraic thinking and equations.",
92
- },
93
- {
94
- "videoId": "5I_1G5CNA5E",
95
- "title": "Functions and Their Graphs",
96
- "channelTitle": "Khan Academy",
97
- "thumbnailUrl": "https://img.youtube.com/vi/5I_1G5CNA5E/hqdefault.jpg",
98
- "durationSeconds": 685,
99
- "description": "Understanding functions, domain, range, and graphing.",
100
- },
101
- ],
102
- "business math": [
103
- {
104
- "videoId": "Dc2V7_ur_yY",
105
- "title": "Simple Interest and Compound Interest",
106
- "channelTitle": "Khan Academy",
107
- "thumbnailUrl": "https://img.youtube.com/vi/Dc2V7_ur_yY/hqdefault.jpg",
108
- "durationSeconds": 780,
109
- "description": "Understanding interest calculations for business applications.",
110
- },
111
- {
112
- "videoId": "BFGj4mkHbHc",
113
- "title": "Business Mathematics Tutorial",
114
- "channelTitle": "Math Meeting",
115
- "thumbnailUrl": "https://img.youtube.com/vi/BFGj4mkHbHc/hqdefault.jpg",
116
- "durationSeconds": 890,
117
- "description": "Essential business math concepts and problem solving.",
118
- },
119
- ],
120
- "statistics": [
121
- {
122
- "videoId": "qBigTkBLU6g",
123
- "title": "Statistics Intro: Mean, Median, and Mode",
124
- "channelTitle": "Khan Academy",
125
- "thumbnailUrl": "https://img.youtube.com/vi/qBigTkBLU6g/hqdefault.jpg",
126
- "durationSeconds": 512,
127
- "description": "Introduction to measures of central tendency.",
128
- },
129
- {
130
- "videoId": "oXdM3XVCzIM",
131
- "title": "Standard Deviation Explained",
132
- "channelTitle": "Khan Academy",
133
- "thumbnailUrl": "https://img.youtube.com/vi/oXdM3XVCzIM/hqdefault.jpg",
134
- "durationSeconds": 635,
135
- "description": "Understanding variance and standard deviation.",
136
- },
137
- ],
138
- "probability": [
139
- {
140
- "videoId": "uzkc-qNVoOk",
141
- "title": "Probability Explained",
142
- "channelTitle": "Khan Academy",
143
- "thumbnailUrl": "https://img.youtube.com/vi/uzkc-qNVoOk/hqdefault.jpg",
144
- "durationSeconds": 480,
145
- "description": "Introduction to probability concepts and calculations.",
146
- },
147
- {
148
- "videoId": "SkidyvDkNYQ",
149
- "title": "Probability of Independent Events",
150
- "channelTitle": "Khan Academy",
151
- "thumbnailUrl": "https://img.youtube.com/vi/SkidyvDkNYQ/hqdefault.jpg",
152
- "durationSeconds": 520,
153
- "description": "Calculating probabilities for independent and dependent events.",
154
- },
155
- ],
156
- "finite math": [
157
- {
158
- "videoId": "fNk_zzaMoSs",
159
- "title": "Introduction to Algebra",
160
- "channelTitle": "Khan Academy",
161
- "thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
162
- "durationSeconds": 720,
163
- "description": "Fundamentals of algebraic thinking and equations.",
164
- },
165
- {
166
- "videoId": "5I_1G5CNA5E",
167
- "title": "Functions and Their Graphs",
168
- "channelTitle": "Khan Academy",
169
- "thumbnailUrl": "https://img.youtube.com/vi/5I_1G5CNA5E/hqdefault.jpg",
170
- "durationSeconds": 685,
171
- "description": "Understanding functions, domain, range, and graphing.",
172
- },
173
- ],
174
- "calculus": [
175
- {
176
- "videoId": "p6j8HhfJ5Mc",
177
- "title": "The Essence of Calculus",
178
- "channelTitle": "3Blue1Brown",
179
- "thumbnailUrl": "https://img.youtube.com/vi/p6j8HhfJ5Mc/hqdefault.jpg",
180
- "durationSeconds": 1024,
181
- "description": "A beautiful introduction to calculus concepts.",
182
- },
183
- {
184
- "videoId": "WUvTyaaNkzM",
185
- "title": "Limits and Continuity",
186
- "channelTitle": "Khan Academy",
187
- "thumbnailUrl": "https://img.youtube.com/vi/WUvTyaaNkzM/hqdefault.jpg",
188
- "durationSeconds": 780,
189
- "description": "Understanding limits and continuity in calculus.",
190
- },
191
- ],
192
- "algebra": [
193
- {
194
- "videoId": "fNk_zzaMoSs",
195
- "title": "Introduction to Algebra",
196
- "channelTitle": "Khan Academy",
197
- "thumbnailUrl": "https://img.youtube.com/vi/fNk_zzaMoSs/hqdefault.jpg",
198
- "durationSeconds": 720,
199
- "description": "Fundamentals of algebraic thinking and equations.",
200
- },
201
- {
202
- "videoId": "5I_1G5CNA5E",
203
- "title": "Functions and Their Graphs",
204
- "channelTitle": "Khan Academy",
205
- "thumbnailUrl": "https://img.youtube.com/vi/5I_1G5CNA5E/hqdefault.jpg",
206
- "durationSeconds": 685,
207
- "description": "Understanding functions, domain, range, and graphing.",
208
- },
209
- ],
210
- "geometry": [
211
- {
212
- "videoId": "302eJ3TzJQU",
213
- "title": "Geometry Introduction",
214
- "channelTitle": "Khan Academy",
215
- "thumbnailUrl": "https://img.youtube.com/vi/302eJ3TzJQU/hqdefault.jpg",
216
- "durationSeconds": 540,
217
- "description": "Basic geometry concepts and terminology.",
218
- },
219
- {
220
- "videoId": "Jn0YxbqEjHk",
221
- "title": "Trigonometry Introduction",
222
- "channelTitle": "Khan Academy",
223
- "thumbnailUrl": "https://img.youtube.com/vi/Jn0YxbqEjHk/hqdefault.jpg",
224
- "durationSeconds": 680,
225
- "description": "Introduction to trigonometric functions and identities.",
226
- },
227
- ],
228
- "trigonometry": [
229
- {
230
- "videoId": "Jn0YxbqEjHk",
231
- "title": "Trigonometry Introduction",
232
- "channelTitle": "Khan Academy",
233
- "thumbnailUrl": "https://img.youtube.com/vi/Jn0YxbqEjHk/hqdefault.jpg",
234
- "durationSeconds": 680,
235
- "description": "Introduction to trigonometric functions and identities.",
236
- },
237
- {
238
- "videoId": "PUB0TaZ7bhA",
239
- "title": "Unit Circle Definition of Trig Functions",
240
- "channelTitle": "Khan Academy",
241
- "thumbnailUrl": "https://img.youtube.com/vi/PUB0TaZ7bhA/hqdefault.jpg",
242
- "durationSeconds": 590,
243
- "description": "Understanding sine and cosine on the unit circle.",
244
- },
245
- ],
246
- }
247
-
248
-
249
- def _get_guaranteed_fallback_videos(subject: str = "", max_results: int = 3) -> List[Dict]:
250
- """Return guaranteed fallback videos when YouTube API returns nothing."""
251
- subject_lower = subject.lower().strip()
252
-
253
- # Try exact subject match
254
- if subject_lower in _GUARANTEED_FALLBACK_VIDEOS:
255
- videos = _GUARANTEED_FALLBACK_VIDEOS[subject_lower]
256
- else:
257
- # Try partial match
258
- matched = False
259
- for key, videos_list in _GUARANTEED_FALLBACK_VIDEOS.items():
260
- if key != "default" and (key in subject_lower or subject_lower in key):
261
- videos = videos_list
262
- matched = True
263
- break
264
- if not matched:
265
- videos = _GUARANTEED_FALLBACK_VIDEOS["default"]
266
-
267
- return videos[:max_results]
268
-
269
-
270
- def _build_youtube_client():
271
- """Lazy-init googleapiclient YouTube client. Returns None if no API key."""
272
- if not YOUTUBE_API_KEY:
273
- return None
274
- try:
275
- from googleapiclient.discovery import build
276
- return build("youtube", "v3", developerKey=YOUTUBE_API_KEY, cache_discovery=False)
277
- except Exception as exc:
278
- logger.warning("Failed to build YouTube client: %s", exc)
279
- return None
280
-
281
-
282
- def _parse_iso8601_duration(duration: str) -> int:
283
- """Parse ISO 8601 duration string like 'PT5M30S' to seconds."""
284
- if not duration:
285
- return 0
286
- hours_match = re.search(r"(\d+)H", duration)
287
- minutes_match = re.search(r"(\d+)M", duration)
288
- seconds_match = re.search(r"(\d+)S", duration)
289
- hours = int(hours_match.group(1)) if hours_match else 0
290
- minutes = int(minutes_match.group(1)) if minutes_match else 0
291
- seconds = int(seconds_match.group(1)) if seconds_match else 0
292
- return hours * 3600 + minutes * 60 + seconds
293
-
294
-
295
- def _is_educational_channel(channel_title: str) -> bool:
296
- """Check if a channel appears to be educational."""
297
- lowered = channel_title.lower().strip()
298
- if lowered in _EDUCATIONAL_CHANNEL_EXACT:
299
- return True
300
- return any(kw in lowered for kw in _EDUCATIONAL_CHANNEL_KEYWORDS)
301
-
302
-
303
- def _score_video_result(item: dict, query: str, topic: str, subject: str) -> float:
304
- """Score a video result for relevance. Higher is better."""
305
- score = 0.0
306
- title = (item.get("title") or "").lower()
307
- description = (item.get("description") or "").lower()
308
- channel = (item.get("channelTitle") or "").lower()
309
- query_lower = query.lower()
310
- topic_lower = topic.lower()
311
- subject_lower = subject.lower() if subject else ""
312
-
313
- # Topic relevance (highest weight)
314
- topic_words = [w for w in topic_lower.split() if len(w) > 2]
315
- for word in topic_words:
316
- if word in title:
317
- score += 4.0
318
- if word in description:
319
- score += 1.5
320
-
321
- # Subject relevance
322
- if subject_lower:
323
- subject_words = [w for w in subject_lower.split() if len(w) > 2]
324
- for word in subject_words:
325
- if word in title:
326
- score += 2.0
327
- if word in description:
328
- score += 0.5
329
-
330
- # Query terms appear in title
331
- for word in query_lower.split():
332
- if len(word) > 2 and word in title:
333
- score += 1.0
334
-
335
- # Educational channel bonus
336
- if _is_educational_channel(channel):
337
- score += 3.0
338
-
339
- # Math/education terms in title
340
- math_terms = ["tutorial", "lesson", "explain", "math", "mathematics",
341
- "solution", "problem", "example", "learn", "how to",
342
- "introduction", "basics", "overview", "guide"]
343
- for term in math_terms:
344
- if term in title:
345
- score += 1.5
346
-
347
- # Duration scoring
348
- duration = item.get("durationSeconds", 0)
349
- if _TARGET_MIN_SECONDS <= duration <= _TARGET_MAX_SECONDS:
350
- score += 2.0
351
- elif _MIN_DURATION_SECONDS <= duration <= _MAX_DURATION_SECONDS:
352
- score += 1.0
353
- elif duration > 0:
354
- score += 0.3 # Still count very short/long videos, just less
355
-
356
- return score
357
-
358
-
359
- def _extract_meaningful_keywords(chunks: List[dict]) -> List[str]:
360
- """Extract meaningful keywords from curriculum chunks."""
361
- keywords: List[str] = []
362
- for chunk in chunks[:3]:
363
- content = str(chunk.get("content", "")).strip()
364
- if not content:
365
- continue
366
- # Split into sentences and take first few
367
- sentences = content.split('.')[:2]
368
- for sentence in sentences:
369
- # Extract important words (nouns, concepts) - heuristic approach
370
- words = re.findall(r'\b[A-Za-z][a-z]{3,}\b', sentence)
371
- # Filter out common stop words
372
- stop_words = {
373
- 'this', 'that', 'with', 'from', 'they', 'have', 'will',
374
- 'would', 'there', 'their', 'what', 'said', 'each',
375
- 'which', 'about', 'could', 'other', 'after', 'first',
376
- 'these', 'think', 'where', 'being', 'every', 'great',
377
- 'might', 'shall', 'while', 'through', 'during', 'before',
378
- 'between', 'among', 'within', 'without', 'against',
379
- 'students', 'student', 'learning', 'learn', 'understand',
380
- 'objective', 'objectives', 'competency', 'competencies',
381
- }
382
- meaningful = [w.lower() for w in words if w.lower() not in stop_words]
383
- keywords.extend(meaningful[:8])
384
-
385
- # Deduplicate while preserving order
386
- seen = set()
387
- unique = []
388
- for kw in keywords:
389
- if kw not in seen and len(kw) > 3:
390
- seen.add(kw)
391
- unique.append(kw)
392
- return unique[:12]
393
-
394
-
395
- def _enrich_query_with_rag(topic: str, subject: str, lesson_context: str = "") -> str:
396
- """
397
- Query the RAG vectorstore to extract curriculum keywords and enrich
398
- the YouTube search query for higher relevance.
399
- """
400
- enriched = topic
401
- if subject:
402
- enriched = f"{enriched} {subject}"
403
- if lesson_context:
404
- # Only add lesson context if it's not too similar to topic
405
- if lesson_context.lower() not in topic.lower():
406
- enriched = f"{enriched} {lesson_context}"
407
-
408
- try:
409
- from rag.curriculum_rag import retrieve_curriculum_context
410
- chunks = retrieve_curriculum_context(
411
- query=topic,
412
- subject=subject if subject else None,
413
- top_k=5,
414
- )
415
- if chunks:
416
- keywords = _extract_meaningful_keywords(chunks)
417
- if keywords:
418
- keyword_str = " ".join(keywords[:10])
419
- enriched = f"{enriched} {keyword_str}"
420
- except Exception as exc:
421
- logger.debug("RAG enrichment skipped: %s", exc)
422
-
423
- # Append standard DepEd/Philippines math context
424
- enriched = f"{enriched} DepEd Philippines mathematics tutorial"
425
- return enriched[:300]
426
-
427
-
428
- def _generate_search_queries_with_ai(
429
- topic: str,
430
- subject: str,
431
- lesson_context: str,
432
- grade_level: str,
433
- ) -> List[str]:
434
- """
435
- Use DeepSeek to generate multiple targeted YouTube search queries.
436
- Falls back to heuristic queries if AI is unavailable.
437
-
438
- Returns a list of queries ordered from most specific to most general.
439
- """
440
- try:
441
- from services.inference_client import InferenceRequest, create_default_client
442
-
443
- prompt = (
444
- f"You are helping find educational YouTube videos for a Filipino senior high school math lesson.\n"
445
- f"Topic: {topic}\n"
446
- f"Subject: {subject}\n"
447
- f"Context: {lesson_context or 'General mathematics lesson'}\n"
448
- f"Grade: {grade_level or 'Grade 11-12'}\n\n"
449
- f"Generate exactly 4 YouTube search queries that would find the most relevant educational videos.\n"
450
- f"Rules:\n"
451
- f"1. Query 1: Most specific - exact topic with 'tutorial' or 'lesson'\n"
452
- f"2. Query 2: Slightly broader - related concepts or prerequisite topics\n"
453
- f"3. Query 3: Even broader - the general subject area with key concepts\n"
454
- f"4. Query 4: Last resort - basic subject + 'introduction' or 'basics'\n"
455
- f"5. Each query should be 3-8 words\n"
456
- f"6. Use terms that real educational channels would use\n"
457
- f"7. If the exact topic is very specific/niche, include related more common topics\n\n"
458
- f"Return ONLY a JSON array of 4 strings, nothing else:\n"
459
- f'["query1", "query2", "query3", "query4"]'
460
- )
461
-
462
- client = create_default_client()
463
- request = InferenceRequest(
464
- messages=[
465
- {"role": "system", "content": "You generate YouTube search queries. Return only JSON arrays."},
466
- {"role": "user", "content": prompt},
467
- ],
468
- task_type="lesson_generation",
469
- max_new_tokens=200,
470
- temperature=0.3,
471
- top_p=0.9,
472
- )
473
- response = client.generate_from_messages(request)
474
-
475
- # Parse JSON array from response
476
- text = response.strip()
477
- # Try to find JSON array
478
- match = re.search(r'\[.*\]', text, re.DOTALL)
479
- if match:
480
- queries = json.loads(match.group())
481
- if isinstance(queries, list) and len(queries) >= 2:
482
- # Validate and clean queries
483
- cleaned = []
484
- for q in queries:
485
- if isinstance(q, str) and len(q.strip()) > 3:
486
- cleaned.append(q.strip()[:200])
487
- if len(cleaned) >= 2:
488
- logger.info("AI generated %d search queries", len(cleaned))
489
- return cleaned
490
- except Exception as exc:
491
- logger.debug("AI query generation failed, using fallback: %s", exc)
492
-
493
- # Fallback heuristic queries
494
- return _generate_fallback_queries(topic, subject, lesson_context)
495
-
496
-
497
- def _generate_fallback_queries(topic: str, subject: str, lesson_context: str) -> List[str]:
498
- """Generate fallback search queries when AI is unavailable."""
499
- queries = [
500
- f"{topic} {subject} tutorial lesson",
501
- f"{topic} mathematics explained",
502
- f"{subject} {topic} how to",
503
- ]
504
-
505
- # Add broader queries
506
- if lesson_context and lesson_context.lower() not in topic.lower():
507
- queries.insert(1, f"{lesson_context} tutorial")
508
-
509
- # Extract core concept from topic (e.g., "quadratic equations" -> "quadratic")
510
- core_words = [w for w in topic.split() if len(w) > 3]
511
- if core_words:
512
- core = core_words[0]
513
- queries.append(f"{core} math lesson introduction")
514
-
515
- # Add subject-level query as last resort
516
- queries.append(f"{subject} basics tutorial")
517
-
518
- # Remove duplicates while preserving order
519
- seen = set()
520
- unique = []
521
- for q in queries:
522
- if q.lower() not in seen:
523
- seen.add(q.lower())
524
- unique.append(q)
525
-
526
- return unique[:5]
527
-
528
-
529
- def _find_related_topics_with_ai(topic: str, subject: str) -> List[str]:
530
- """
531
- When exact topic has no videos, ask DeepSeek for related/similar topics
532
- that are more likely to have educational video content.
533
- """
534
- try:
535
- from services.inference_client import InferenceRequest, create_default_client
536
-
537
- prompt = (
538
- f"The topic '{topic}' in {subject} has very few or no YouTube videos.\n"
539
- f"Suggest 3 related, more commonly taught topics that would have educational videos.\n"
540
- f"These should cover similar or prerequisite concepts.\n"
541
- f"Return ONLY a JSON array of 3 short topic phrases (2-4 words each).\n"
542
- f'["topic1", "topic2", "topic3"]'
543
- )
544
-
545
- client = create_default_client()
546
- request = InferenceRequest(
547
- messages=[
548
- {"role": "system", "content": "You suggest related math topics. Return only JSON arrays."},
549
- {"role": "user", "content": prompt},
550
- ],
551
- task_type="lesson_generation",
552
- max_new_tokens=150,
553
- temperature=0.4,
554
- top_p=0.9,
555
- )
556
- response = client.generate_from_messages(request)
557
-
558
- text = response.strip()
559
- match = re.search(r'\[.*\]', text, re.DOTALL)
560
- if match:
561
- topics = json.loads(match.group())
562
- if isinstance(topics, list):
563
- cleaned = [t.strip()[:100] for t in topics if isinstance(t, str) and len(t.strip()) > 2]
564
- if cleaned:
565
- logger.info("AI suggested %d related topics for '%s'", len(cleaned), topic)
566
- return cleaned
567
- except Exception as exc:
568
- logger.debug("AI related topics failed: %s", exc)
569
-
570
- # Fallback: generate simple related topics
571
- return _generate_fallback_related_topics(topic, subject)
572
-
573
-
574
- def _generate_fallback_related_topics(topic: str, subject: str) -> List[str]:
575
- """Generate simple related topic fallbacks."""
576
- related = []
577
-
578
- # Try subject + common subtopics
579
- if "equation" in topic.lower():
580
- related.extend([f"{subject} functions", f"{subject} graphing"])
581
- elif "function" in topic.lower():
582
- related.extend([f"{subject} equations", f"{subject} domain range"])
583
- elif "probability" in topic.lower():
584
- related.extend([f"{subject} statistics", "basic probability concepts"])
585
- elif "statistics" in topic.lower():
586
- related.extend([f"{subject} data analysis", "measures of central tendency"])
587
- elif "geometry" in topic.lower() or "angle" in topic.lower():
588
- related.extend([f"{subject} trigonometry", "basic geometry concepts"])
589
- elif "calculus" in topic.lower() or "derivative" in topic.lower():
590
- related.extend(["limits and continuity", f"{subject} functions"])
591
- else:
592
- related.extend([
593
- f"{subject} fundamentals",
594
- f"{subject} basic concepts",
595
- f"{subject} introduction",
596
- ])
597
-
598
- return related[:3]
599
-
600
-
601
- def _execute_youtube_search(
602
- client,
603
- query: str,
604
- max_results: int = 15,
605
- video_duration: Optional[str] = "medium",
606
- video_definition: Optional[str] = "high",
607
- language: str = "en",
608
- ) -> List[dict]:
609
- """Execute a single YouTube search and return raw items with details."""
610
- try:
611
- search_params = {
612
- "part": "snippet",
613
- "q": query,
614
- "type": "video",
615
- "maxResults": max_results,
616
- "relevanceLanguage": language,
617
- "order": "relevance",
618
- }
619
-
620
- if video_duration:
621
- search_params["videoDuration"] = video_duration
622
- if video_definition:
623
- search_params["videoDefinition"] = video_definition
624
-
625
- search_response = client.search().list(**search_params).execute()
626
- items = search_response.get("items", [])
627
-
628
- if not items:
629
- return []
630
-
631
- # Get video details
632
- video_ids = [item["id"]["videoId"] for item in items if item.get("id", {}).get("videoId")]
633
- if not video_ids:
634
- return []
635
-
636
- details_response = client.videos().list(
637
- part="contentDetails,statistics,snippet",
638
- id=",".join(video_ids),
639
- ).execute()
640
-
641
- details_map = {}
642
- for detail in details_response.get("items", []):
643
- vid = detail.get("id")
644
- if vid:
645
- details_map[vid] = detail
646
-
647
- # Build enriched items
648
- results = []
649
- for item in items:
650
- video_id = item.get("id", {}).get("videoId", "")
651
- if not video_id:
652
- continue
653
-
654
- detail = details_map.get(video_id, {})
655
- snippet = detail.get("snippet", item.get("snippet", {}))
656
- content_details = detail.get("contentDetails", {})
657
-
658
- duration = content_details.get("duration", "")
659
- duration_secs = _parse_iso8601_duration(duration)
660
-
661
- # Build thumbnail URL
662
- thumbnail_url = f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg"
663
- thumbs = snippet.get("thumbnails", {})
664
- if "high" in thumbs:
665
- thumbnail_url = thumbs["high"]["url"]
666
- elif "medium" in thumbs:
667
- thumbnail_url = thumbs["medium"]["url"]
668
-
669
- results.append({
670
- "videoId": video_id,
671
- "title": snippet.get("title", ""),
672
- "channelTitle": snippet.get("channelTitle", ""),
673
- "thumbnailUrl": thumbnail_url,
674
- "durationSeconds": duration_secs,
675
- "description": snippet.get("description", "")[:300],
676
- })
677
-
678
- return results
679
- except Exception as exc:
680
- logger.warning("YouTube search execution failed for query '%s': %s", query, exc)
681
- return []
682
-
683
-
684
- def _filter_and_score_results(
685
- items: List[dict],
686
- query: str,
687
- topic: str,
688
- subject: str,
689
- require_educational: bool = True,
690
- min_duration: int = 120,
691
- max_duration: int = 3600,
692
- ) -> List[dict]:
693
- """Filter and score video results."""
694
- results = []
695
- for item in items:
696
- duration_secs = item.get("durationSeconds", 0)
697
- channel_title = item.get("channelTitle", "")
698
- title = item.get("title", "")
699
-
700
- # Duration filter
701
- if duration_secs < min_duration or duration_secs > max_duration:
702
- continue
703
-
704
- # Educational channel filter
705
- is_edu = _is_educational_channel(channel_title)
706
- if require_educational and not is_edu:
707
- # Allow if title strongly suggests math tutorial
708
- lowered_title = title.lower()
709
- if not any(term in lowered_title for term in [
710
- "tutorial", "lesson", "math", "explain", "how to",
711
- "introduction", "basics", "learn", "example", "problem"
712
- ]):
713
- continue
714
-
715
- # Score
716
- score = _score_video_result(item, query, topic, subject)
717
- item["_score"] = score
718
- results.append(item)
719
-
720
- results.sort(key=lambda x: x["_score"], reverse=True)
721
- for r in results:
722
- r.pop("_score", None)
723
-
724
- return results
725
-
726
-
727
- def _get_cache_key(topic: str, subject: str, grade_level: str) -> str:
728
- """Generate a deterministic Firestore document ID for caching."""
729
- raw = f"{subject}|{topic}|{grade_level}"
730
- return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
731
-
732
-
733
- def get_cached_videos(lesson_id: str) -> Optional[List[Dict]]:
734
- """Check Firestore video_cache/{lessonId} for cached results (TTL 7 days)."""
735
- try:
736
- import firebase_admin
737
- from firebase_admin import firestore
738
- if not firebase_admin._apps:
739
- return None
740
-
741
- db = firestore.client()
742
- doc_ref = db.collection("video_cache").document(lesson_id)
743
- doc = doc_ref.get()
744
- if not doc.exists:
745
- return None
746
-
747
- data = doc.to_dict()
748
- if not data:
749
- return None
750
-
751
- cached_at = data.get("cachedAt")
752
- if cached_at:
753
- if hasattr(cached_at, "timestamp"):
754
- cached_epoch = cached_at.timestamp()
755
- elif isinstance(cached_at, datetime):
756
- cached_epoch = cached_at.timestamp()
757
- else:
758
- cached_epoch = float(cached_at)
759
- now_epoch = datetime.now(timezone.utc).timestamp()
760
- if (now_epoch - cached_epoch) > _CACHE_TTL_SECONDS:
761
- logger.info("Video cache expired for lesson %s", lesson_id)
762
- return None
763
-
764
- videos = data.get("videos")
765
- if isinstance(videos, list) and len(videos) > 0:
766
- logger.info("Video cache hit for lesson %s (%d videos)", lesson_id, len(videos))
767
- return videos
768
- except Exception as exc:
769
- logger.debug("Could not read video cache: %s", exc)
770
- return None
771
-
772
-
773
- def cache_videos(lesson_id: str, videos: List[Dict], topic: str) -> None:
774
- """Store search results in Firestore video_cache/{lessonId}."""
775
- try:
776
- import firebase_admin
777
- from firebase_admin import firestore
778
- if not firebase_admin._apps:
779
- return
780
-
781
- db = firestore.client()
782
- db.collection("video_cache").document(lesson_id).set({
783
- "videos": videos,
784
- "cachedAt": firestore.SERVER_TIMESTAMP,
785
- "topic": topic,
786
- })
787
- logger.info("Cached %d videos for lesson %s", len(videos), lesson_id)
788
- except Exception as exc:
789
- logger.warning("Could not cache videos in Firestore: %s", exc)
790
-
791
-
792
- def search_youtube_videos(
793
- topic: str,
794
- subject: str = "",
795
- lesson_context: str = "",
796
- grade_level: str = "",
797
- max_results: int = 3,
798
- language: str = "en",
799
- ) -> List[Dict]:
800
- """
801
- Search YouTube Data API v3 for relevant educational math videos.
802
-
803
- Uses a multi-strategy approach to guarantee at least 1 result:
804
- 1. AI-generated targeted queries with strict filters
805
- 2. Fallback to heuristic queries with relaxed filters
806
- 3. Broader subject-level searches
807
- 4. Related topics suggested by AI
808
- 5. Emergency unfiltered search as last resort
809
-
810
- Returns up to `max_results` videos.
811
- """
812
- client = _build_youtube_client()
813
- if client is None:
814
- logger.warning("YOUTUBE_API_KEY not set. Video search disabled.")
815
- return []
816
-
817
- all_results: List[dict] = []
818
- seen_video_ids = set()
819
-
820
- # Generate search queries using AI + fallback
821
- queries = _generate_search_queries_with_ai(topic, subject, lesson_context, grade_level)
822
- logger.info("YouTube search queries: %s", queries)
823
-
824
- # ─── Strategy 1: AI queries with standard filters ───────────────────────
825
- for query in queries:
826
- items = _execute_youtube_search(
827
- client, query,
828
- max_results=10,
829
- video_duration="medium",
830
- video_definition="high",
831
- language=language,
832
- )
833
- filtered = _filter_and_score_results(
834
- items, query, topic, subject,
835
- require_educational=True,
836
- min_duration=_MIN_DURATION_SECONDS,
837
- max_duration=_MAX_DURATION_SECONDS,
838
- )
839
- for item in filtered:
840
- vid = item["videoId"]
841
- if vid not in seen_video_ids:
842
- seen_video_ids.add(vid)
843
- all_results.append(item)
844
-
845
- if len(all_results) >= max_results:
846
- break
847
-
848
- # ─── Strategy 2: Same queries, relaxed filters ──────────────────────────
849
- if len(all_results) < max_results:
850
- for query in queries:
851
- items = _execute_youtube_search(
852
- client, query,
853
- max_results=10,
854
- video_duration=None, # Any duration
855
- video_definition=None, # Any quality
856
- language=language,
857
- )
858
- filtered = _filter_and_score_results(
859
- items, query, topic, subject,
860
- require_educational=False, # Less strict
861
- min_duration=60, # Allow shorter
862
- max_duration=7200, # Allow longer
863
- )
864
- for item in filtered:
865
- vid = item["videoId"]
866
- if vid not in seen_video_ids:
867
- seen_video_ids.add(vid)
868
- all_results.append(item)
869
-
870
- if len(all_results) >= max_results:
871
- break
872
-
873
- # ─── Strategy 3: Broader subject-level searches ─────────────────────────
874
- if len(all_results) < 1:
875
- broad_queries = [
876
- f"{subject} {topic.split()[0] if topic else ''} tutorial",
877
- f"{subject} mathematics lesson",
878
- f"{topic} explained simply",
879
- ]
880
- for query in broad_queries:
881
- if not query.strip():
882
- continue
883
- items = _execute_youtube_search(
884
- client, query,
885
- max_results=10,
886
- video_duration=None,
887
- video_definition=None,
888
- language=language,
889
- )
890
- filtered = _filter_and_score_results(
891
- items, query, topic, subject,
892
- require_educational=False,
893
- min_duration=60,
894
- max_duration=7200,
895
- )
896
- for item in filtered:
897
- vid = item["videoId"]
898
- if vid not in seen_video_ids:
899
- seen_video_ids.add(vid)
900
- all_results.append(item)
901
-
902
- if len(all_results) >= max_results:
903
- break
904
-
905
- # ─── Strategy 4: AI-suggested related topics ────────────────────────────
906
- if len(all_results) < 1:
907
- related_topics = _find_related_topics_with_ai(topic, subject)
908
- for related_topic in related_topics:
909
- query = f"{related_topic} tutorial"
910
- items = _execute_youtube_search(
911
- client, query,
912
- max_results=8,
913
- video_duration=None,
914
- video_definition=None,
915
- language=language,
916
- )
917
- filtered = _filter_and_score_results(
918
- items, query, topic, subject,
919
- require_educational=False,
920
- min_duration=60,
921
- max_duration=7200,
922
- )
923
- for item in filtered:
924
- vid = item["videoId"]
925
- if vid not in seen_video_ids:
926
- seen_video_ids.add(vid)
927
- all_results.append(item)
928
-
929
- if len(all_results) >= max_results:
930
- break
931
-
932
- # ─── Strategy 5: Emergency unfiltered search ────────────────────────────
933
- if len(all_results) < 1:
934
- emergency_queries = [
935
- topic,
936
- f"{topic} math",
937
- subject,
938
- ]
939
- for query in emergency_queries:
940
- if not query or not query.strip():
941
- continue
942
- items = _execute_youtube_search(
943
- client, query,
944
- max_results=5,
945
- video_duration=None,
946
- video_definition=None,
947
- language=language,
948
- )
949
- # Accept ANY result in emergency mode
950
- for item in items:
951
- vid = item["videoId"]
952
- if vid not in seen_video_ids:
953
- seen_video_ids.add(vid)
954
- all_results.append(item)
955
-
956
- if len(all_results) >= 1:
957
- break
958
-
959
- # ─── Final: Return top results or guaranteed fallback ───────────────────
960
- if not all_results:
961
- logger.warning(
962
- "All YouTube search strategies failed for topic: %s. Using guaranteed fallback videos.",
963
- topic,
964
- )
965
- fallback = _get_guaranteed_fallback_videos(subject, max_results)
966
- if fallback:
967
- logger.info("Returning %d guaranteed fallback videos for subject: %s", len(fallback), subject)
968
- return fallback
969
- return []
970
-
971
- # Re-score all collected results against the original topic
972
- for item in all_results:
973
- item["_score"] = _score_video_result(item, topic, topic, subject)
974
-
975
- all_results.sort(key=lambda x: x["_score"], reverse=True)
976
- for item in all_results:
977
- item.pop("_score", None)
978
-
979
- top_results = all_results[:max_results]
980
- logger.info("YouTube search returned %d results (top %d) for topic: %s",
981
- len(all_results), len(top_results), topic)
982
- return top_results
983
-
984
-
985
- def get_video_search_results(
986
- topic: str,
987
- subject: str = "",
988
- lesson_context: str = "",
989
- grade_level: str = "",
990
- lesson_id: Optional[str] = None,
991
- max_results: int = 3,
992
- ) -> Dict:
993
- """
994
- High-level wrapper: check cache first, then search YouTube, then cache results.
995
-
996
- Returns {"videos": [...], "cached": bool}.
997
- """
998
- cache_key = lesson_id or _get_cache_key(topic, subject, grade_level)
999
-
1000
- # Check cache first
1001
- cached = get_cached_videos(cache_key)
1002
- if cached is not None:
1003
- return {"videos": cached, "cached": True}
1004
-
1005
- # Search YouTube
1006
- videos = search_youtube_videos(
1007
- topic=topic,
1008
- subject=subject,
1009
- lesson_context=lesson_context,
1010
- grade_level=grade_level,
1011
- max_results=max_results,
1012
- )
1013
-
1014
- if videos:
1015
- cache_videos(cache_key, videos, topic)
1016
-
1017
- return {"videos": videos, "cached": False}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
startup.sh CHANGED
@@ -11,33 +11,12 @@ fi
11
 
12
  export CURRICULUM_DIR
13
  export VECTORSTORE_DIR
14
- export CURRICULUM_VECTORSTORE_DIR="${VECTORSTORE_DIR}"
15
-
16
- echo "=========================================="
17
- echo "MathPulse AI Startup"
18
- echo "=========================================="
19
- echo "VECTORSTORE_DIR=${VECTORSTORE_DIR}"
20
- echo "CURRICULUM_VECTORSTORE_DIR=${CURRICULUM_VECTORSTORE_DIR}"
21
- echo "CURRICULUM_SOURCE_REPO_ID set: $(if [ -n "${CURRICULUM_SOURCE_REPO_ID:-}" ]; then echo YES; else echo NO; fi)"
22
- echo "FIREBASE_SERVICE_ACCOUNT_JSON set: $(if [ -n "${FIREBASE_SERVICE_ACCOUNT_JSON:-}" ]; then echo YES; else echo NO; fi)"
23
- echo "FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET:-not set}"
24
- echo "=========================================="
25
 
26
  mkdir -p "${CURRICULUM_DIR}" "${VECTORSTORE_DIR}"
27
 
28
- _vectorstore_cache_dir="${VECTORSTORE_DIR}/.chroma"
29
- if [ ! -d "${_vectorstore_cache_dir}" ]; then
30
- mkdir -p "${_vectorstore_cache_dir}"
31
- echo "INFO: Initialized ChromaDB cache dir at ${_vectorstore_cache_dir}"
32
- fi
33
-
34
  _ingest_script="/app/scripts/ingest_curriculum.py"
35
  if [ -f "${_ingest_script}" ]; then
36
- _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,27 +26,12 @@ 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
 
11
 
12
  export CURRICULUM_DIR
13
  export VECTORSTORE_DIR
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  mkdir -p "${CURRICULUM_DIR}" "${VECTORSTORE_DIR}"
16
 
 
 
 
 
 
 
17
  _ingest_script="/app/scripts/ingest_curriculum.py"
18
  if [ -f "${_ingest_script}" ]; then
19
+ if [ -n "${CURRICULUM_SOURCE_REPO_ID:-}" ] || find "${CURRICULUM_DIR}" -type f -name '*.pdf' -print -quit >/dev/null 2>&1; then
 
 
 
 
20
  echo "INFO: Running curriculum ingestion (optional)..."
21
  python "${_ingest_script}" && echo "INFO: Curriculum ingestion completed" || echo "WARNING: Curriculum ingestion failed, continuing anyway"
22
  else
 
26
  echo "INFO: Curriculum ingestion script not found at ${_ingest_script}; skipping (curriculum is optional)"
27
  fi
28
 
29
+ _download_script="/app/scripts/download_vectorstore_from_firebase.py"
30
+ if [ -f "${_download_script}" ]; then
 
 
31
  echo "INFO: Downloading vectorstore from Firebase Storage..."
32
+ python "${_download_script}" && echo "INFO: Vectorstore download completed" || echo "WARNING: Vectorstore download failed, continuing anyway"
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  else
34
+ echo "INFO: Vectorstore download script not found at ${_download_script}; skipping (vectorstore is optional)"
35
  fi
36
 
37
  exec uvicorn main:app --host 0.0.0.0 --port 7860 --workers 1
startup_validation.py CHANGED
@@ -32,12 +32,7 @@ def validate_imports() -> None:
32
  logger.info(" ✓ FastAPI, Uvicorn, Pydantic OK")
33
 
34
  # Backend services (use ABSOLUTE imports like deployed code)
35
- from services.inference_client import (
36
- InferenceClient, create_default_client, is_sequential_model,
37
- get_current_runtime_config, get_model_for_task, model_supports_thinking,
38
- set_runtime_model_profile, set_runtime_model_override, reset_runtime_overrides,
39
- _MODEL_PROFILES,
40
- ) # noqa
41
  logger.info(" ✓ InferenceClient imports OK")
42
 
43
  from automation_engine import automation_engine # noqa
@@ -54,8 +49,8 @@ def validate_imports() -> None:
54
  logger.warning(" ⚠ firebase_admin not available (OK if Firebase not needed)")
55
 
56
  # ML & inference
57
- from 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:
@@ -78,37 +73,36 @@ 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:
 
 
84
  logger.warning(
85
- "⚠ WARNING: DEEPSEEK_API_KEY is not set as an environment variable.\n"
 
86
  " AI inference will fail without this token.\n"
87
- " Use: Set DEEPSEEK_API_KEY in your .env or space secrets."
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}")
99
 
100
  chat_strict = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
101
  chat_hard_trigger = os.getenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
102
- enforce_lock_model = os.getenv("INFERENCE_ENFORCE_LOCK_MODEL", "true").strip().lower() in {"1", "true", "yes", "on"}
103
- lock_model_id = os.getenv("INFERENCE_LOCK_MODEL_ID", "deepseek-chat").strip() or "deepseek-chat"
104
- logger.info(f" ✓ INFERENCE_ENFORCE_LOCK_MODEL: {enforce_lock_model}")
105
- logger.info(f" ✓ INFERENCE_LOCK_MODEL_ID: {lock_model_id}")
106
- model_profile = os.getenv("MODEL_PROFILE", "").strip().lower()
107
- quiz_model = os.getenv("HF_QUIZ_MODEL_ID", "").strip()
108
- rag_model = os.getenv("HF_RAG_MODEL_ID", "").strip()
109
- logger.info(f" ✓ MODEL_PROFILE: {model_profile or 'not set (using individual env vars)'}")
110
- logger.info(f" ✓ HF_QUIZ_MODEL_ID: {quiz_model or 'not set (using defaults)'}")
111
- logger.info(f" ✓ HF_RAG_MODEL_ID: {rag_model or 'not set (using defaults)'}")
112
  if not chat_strict:
113
  logger.warning(" ⚠ Chat strict model lock is disabled; chat may fallback to alternate models")
114
  if chat_strict and chat_hard_trigger:
@@ -116,40 +110,9 @@ def validate_environment() -> None:
116
  " ⚠ Chat hard trigger is enabled while strict chat lock is on; hard escalation will be bypassed"
117
  )
118
 
119
- _validate_embedding_model()
120
-
121
  logger.info("✅ Environment variables OK")
122
 
123
 
124
- EXPECTED_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
125
-
126
- def _validate_embedding_model() -> None:
127
- embedding_model = os.getenv("EMBEDDING_MODEL", "").strip()
128
- if not embedding_model:
129
- logger.warning(
130
- "WARNING: EMBEDDING_MODEL env var is not set. "
131
- f"Expected: {EXPECTED_EMBEDDING_MODEL}. "
132
- "RAG retrieval will fail without an embedding model."
133
- )
134
- elif embedding_model != EXPECTED_EMBEDDING_MODEL:
135
- logger.warning(
136
- f"WARNING: EMBEDDING_MODEL is set to '{embedding_model}' — "
137
- f"expected '{EXPECTED_EMBEDDING_MODEL}'. "
138
- "Confirm this is intentional before deploying."
139
- )
140
- from services.ai_client import CHAT_MODEL, REASONER_MODEL # noqa
141
- generation_model_ids = [
142
- CHAT_MODEL, REASONER_MODEL,
143
- ]
144
- if embedding_model in generation_model_ids:
145
- logger.warning(
146
- f"CRITICAL: EMBEDDING_MODEL is set to a generation model ('{embedding_model}'). "
147
- "This will break RAG retrieval. Set it to 'BAAI/bge-small-en-v1.5'."
148
- )
149
- else:
150
- logger.info(f" EMBEDDING_MODEL: {embedding_model or 'not set'}")
151
-
152
-
153
  def validate_config_files() -> None:
154
  """Verify config files exist and are readable."""
155
  logger.info("🔍 Validating configuration files...")
@@ -191,9 +154,7 @@ def validate_config_files() -> None:
191
  )
192
 
193
  logger.info(f" ✓ Using model config: {readable_model_config}")
194
-
195
- _validate_model_config_fields(readable_model_config)
196
-
197
  logger.info("✅ Configuration files OK")
198
 
199
 
@@ -297,40 +258,6 @@ def validate_inference_client_config() -> None:
297
  ) from e
298
 
299
 
300
- def _validate_model_config_fields(config_path: str) -> None:
301
- try:
302
- import yaml
303
- with open(config_path, "r", encoding="utf-8") as f:
304
- config = yaml.safe_load(f) or {}
305
- except Exception as e:
306
- raise StartupError(f"❌ Cannot parse {config_path} as YAML: {e}") from e
307
-
308
- models = config.get("models", {})
309
- if not isinstance(models, dict):
310
- raise StartupError(f"❌ {config_path}: 'models' section missing or invalid")
311
-
312
- if "rag_primary" not in models:
313
- raise StartupError(f"❌ {config_path}: missing 'models.rag_primary' field")
314
- rag_primary = models["rag_primary"]
315
- if isinstance(rag_primary, dict):
316
- logger.info(f" ✓ rag_primary model: {rag_primary.get('id', 'UNSET')}")
317
- else:
318
- logger.warning(f" ⚠ rag_primary is not a dict, may cause issues")
319
-
320
- capabilities = models.get("model_capabilities")
321
- if not isinstance(capabilities, dict):
322
- raise StartupError(f"❌ {config_path}: missing 'models.model_capabilities' section")
323
- logger.info(f" ✓ model_capabilities: sequential_only={capabilities.get('sequential_only')}, supports_thinking={capabilities.get('supports_thinking')}")
324
-
325
- tasks = config.get("routing", {}).get("task_model_map", {})
326
- rag_tasks = {"rag_lesson", "rag_problem", "rag_analysis_context"}
327
- missing_rag = rag_tasks - set(str(t).strip().lower() for t in tasks.keys())
328
- if missing_rag:
329
- raise StartupError(f"❌ {config_path}: missing RAG task mappings: {missing_rag}")
330
-
331
- logger.info(f" ✓ All RAG task mappings present")
332
-
333
-
334
  def run_all_validations() -> None:
335
  """Run comprehensive startup validation.
336
 
 
32
  logger.info(" ✓ FastAPI, Uvicorn, Pydantic OK")
33
 
34
  # Backend services (use ABSOLUTE imports like deployed code)
35
+ from services.inference_client import InferenceClient, create_default_client # noqa
 
 
 
 
 
36
  logger.info(" ✓ InferenceClient imports OK")
37
 
38
  from automation_engine import automation_engine # noqa
 
49
  logger.warning(" ⚠ firebase_admin not available (OK if Firebase not needed)")
50
 
51
  # ML & inference
52
+ from huggingface_hub import InferenceClient as HFInferenceClient # noqa
53
+ logger.info(" ✓ HuggingFace Hub imports OK")
54
 
55
  logger.info("✅ All critical imports validated")
56
  except ImportError as e:
 
73
  """Verify required environment variables are set."""
74
  logger.info("🔍 Validating environment variables...")
75
 
76
+ # CRITICAL: HF_TOKEN for inference
77
+ hf_token = os.environ.get("HF_TOKEN")
78
+ api_key = os.environ.get("HUGGING_FACE_API_TOKEN")
79
+ legacy_api_key = os.environ.get("HUGGINGFACE_API_TOKEN")
80
+ if not hf_token and not api_key and not legacy_api_key:
81
  logger.warning(
82
+ "⚠ WARNING: HF_TOKEN is not set as an environment variable.\n"
83
+ " On HF Spaces, this should be set as a SPACE SECRET.\n"
84
  " AI inference will fail without this token.\n"
85
+ " Use: python set-hf-secrets.py to set the secret."
86
  )
87
  else:
88
+ logger.info(" ✓ HF_TOKEN/HUGGING_FACE_API_TOKEN/HUGGINGFACE_API_TOKEN is set")
89
 
90
  # Check inference provider config
91
+ inference_provider = os.getenv("INFERENCE_PROVIDER", "hf_inference")
92
  logger.info(f" ✓ INFERENCE_PROVIDER: {inference_provider}")
93
 
94
  # Check model IDs
95
+ chat_model = os.getenv("INFERENCE_CHAT_MODEL_ID") or os.getenv("INFERENCE_MODEL_ID") or "Qwen/Qwen3-32B"
96
  logger.info(f" ✓ Chat model configured: {chat_model}")
97
 
98
  chat_strict = os.getenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
99
  chat_hard_trigger = os.getenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "false").strip().lower() in {"1", "true", "yes", "on"}
100
+ enforce_qwen_only = os.getenv("INFERENCE_ENFORCE_QWEN_ONLY", "true").strip().lower() in {"1", "true", "yes", "on"}
101
+ qwen_lock_model = os.getenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen3-32B").strip() or "Qwen/Qwen3-32B"
102
+ logger.info(f" ✓ INFERENCE_CHAT_STRICT_MODEL_ONLY: {chat_strict}")
103
+ logger.info(f" ✓ INFERENCE_CHAT_HARD_TRIGGER_ENABLED: {chat_hard_trigger}")
104
+ logger.info(f" INFERENCE_ENFORCE_QWEN_ONLY: {enforce_qwen_only}")
105
+ logger.info(f" INFERENCE_QWEN_LOCK_MODEL: {qwen_lock_model}")
 
 
 
 
106
  if not chat_strict:
107
  logger.warning(" ⚠ Chat strict model lock is disabled; chat may fallback to alternate models")
108
  if chat_strict and chat_hard_trigger:
 
110
  " ⚠ Chat hard trigger is enabled while strict chat lock is on; hard escalation will be bypassed"
111
  )
112
 
 
 
113
  logger.info("✅ Environment variables OK")
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  def validate_config_files() -> None:
117
  """Verify config files exist and are readable."""
118
  logger.info("🔍 Validating configuration files...")
 
154
  )
155
 
156
  logger.info(f" ✓ Using model config: {readable_model_config}")
157
+
 
 
158
  logger.info("✅ Configuration files OK")
159
 
160
 
 
258
  ) from e
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  def run_all_validations() -> None:
262
  """Run comprehensive startup validation.
263
 
test_full_rag.py DELETED
@@ -1,75 +0,0 @@
1
- import sys
2
- import os
3
- sys.path.insert(0, 'backend')
4
-
5
- # Set required env vars
6
- os.environ['DEEPSEEK_API_KEY'] = os.getenv('DEEPSEEK_API_KEY', '')
7
- os.environ['DEEPSEEK_BASE_URL'] = os.getenv('DEEPSEEK_BASE_URL', 'https://api.deepseek.com')
8
-
9
- from rag.curriculum_rag import retrieve_lesson_pdf_context, build_lesson_prompt
10
- from services.inference_client import InferenceClient, InferenceRequest
11
-
12
- # Test retrieval
13
- print("Testing retrieval...")
14
- try:
15
- chunks, mode = retrieve_lesson_pdf_context(
16
- topic="Represent real-life relationships as functions and interpret domain/range.",
17
- subject="General Mathematics",
18
- quarter=2,
19
- lesson_title="Represent real-life relationships as functions and interpret domain/range.",
20
- module_id="gen-math",
21
- lesson_id="gm-q2-functions-graphs-l1",
22
- competency_code="GM11-FG-1",
23
- top_k=8,
24
- )
25
- print(f"Retrieved {len(chunks)} chunks, mode={mode}")
26
- except Exception as e:
27
- print(f"Retrieval ERROR: {type(e).__name__}: {e}")
28
- import traceback
29
- traceback.print_exc()
30
- sys.exit(1)
31
-
32
- # Test prompt building
33
- print("\nTesting prompt building...")
34
- try:
35
- prompt = build_lesson_prompt(
36
- lesson_title="Represent real-life relationships as functions and interpret domain/range.",
37
- competency="Represent real-life relationships as functions and interpret domain/range.",
38
- grade_level="Grade 11-12",
39
- subject="General Mathematics",
40
- quarter=2,
41
- learner_level="Grade 11-12",
42
- module_unit="n/a",
43
- curriculum_chunks=chunks,
44
- competency_code="GM11-FG-1",
45
- )
46
- print(f"Prompt length: {len(prompt)} chars")
47
- print(f"Prompt preview: {prompt[:200]}...")
48
- except Exception as e:
49
- print(f"Prompt building ERROR: {type(e).__name__}: {e}")
50
- import traceback
51
- traceback.print_exc()
52
- sys.exit(1)
53
-
54
- # Test inference (optional - might cost money)
55
- print("\nTesting inference...")
56
- try:
57
- client = InferenceClient()
58
- req = InferenceRequest(
59
- messages=[
60
- {"role": "system", "content": "You are a precise DepEd-aligned curriculum assistant."},
61
- {"role": "user", "content": prompt},
62
- ],
63
- task_type="lesson_generation",
64
- max_new_tokens=100, # Small for testing
65
- temperature=0.2,
66
- top_p=0.9,
67
- enable_thinking=True,
68
- )
69
- result = client.generate_from_messages(req)
70
- print(f"Inference result: {result[:200]}...")
71
- print("SUCCESS!")
72
- except Exception as e:
73
- print(f"Inference ERROR: {type(e).__name__}: {e}")
74
- import traceback
75
- traceback.print_exc()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
test_retrieval.py DELETED
@@ -1,39 +0,0 @@
1
- import sys
2
- sys.path.insert(0, '.')
3
-
4
- from rag.curriculum_rag import retrieve_lesson_pdf_context, retrieve_curriculum_context
5
-
6
- # Test retrieval with the same params as the frontend
7
- try:
8
- chunks, mode = retrieve_lesson_pdf_context(
9
- topic="Represent real-life relationships as functions and interpret domain/range.",
10
- subject="General Mathematics",
11
- quarter=2,
12
- lesson_title="Represent real-life relationships as functions and interpret domain/range.",
13
- module_id="gen-math",
14
- lesson_id="gm-q2-functions-graphs-l1",
15
- competency_code="GM11-FG-1",
16
- top_k=8,
17
- )
18
- print(f"Retrieved {len(chunks)} chunks, mode={mode}")
19
- for i, chunk in enumerate(chunks[:3]):
20
- print(f" Chunk {i}: score={chunk.get('score')}, domain={chunk.get('content_domain')}, source={chunk.get('source_file')}")
21
- print(f" Content: {chunk.get('content', '')[:100]}...")
22
- except Exception as e:
23
- print(f"ERROR: {type(e).__name__}: {e}")
24
- import traceback
25
- traceback.print_exc()
26
-
27
- # Also test without module/lesson filters
28
- try:
29
- chunks2 = retrieve_curriculum_context(
30
- query="Represent real-life relationships as functions and interpret domain/range.",
31
- subject="General Mathematics",
32
- quarter=2,
33
- top_k=8,
34
- )
35
- print(f"\nGeneral retrieval: {len(chunks2)} chunks")
36
- except Exception as e:
37
- print(f"\nGeneral ERROR: {type(e).__name__}: {e}")
38
- import traceback
39
- traceback.print_exc()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/README.md DELETED
@@ -1,46 +0,0 @@
1
- # Backend Tests Safe Runner
2
-
3
- ## Test Pollution Issue
4
- The test suite has pollution when run in default pytest order. Tests pass in isolation or in specific groupings.
5
-
6
- ## Running Tests Safely
7
-
8
- ### Option 1: Run core API tests only (137 tests, all green)
9
- ```bash
10
- cd backend
11
- python -m pytest tests/test_api.py tests/test_rag_pipeline.py tests/test_quiz_battle.py tests/test_model_profiles.py -v
12
- ```
13
-
14
- ### Option 2: Run key test files in correct order
15
- ```bash
16
- python -m pytest tests/ -v --ignore=tests/test_video_routes.py --ignore=tests/test_admin_model_routes.py --ignore=tests/test_hf_monitoring_routes.py
17
- ```
18
-
19
- ### Option 3: Individual test files (all green individually)
20
- ```bash
21
- # Each passes individually
22
- python -m pytest tests/test_api.py -v # 90 passed
23
- python -m pytest tests/test_rag_pipeline.py -v # 13 passed
24
- python -m pytest tests/test_quiz_battle.py -v # 19 passed
25
- python -m pytest tests/test_model_profiles.py -v # 15 passed
26
- python -m pytest tests/test_video_routes.py -v # 11 passed
27
- python -m pytest tests/test_admin_model_routes.py -v # 19 passed
28
- python -m pytest tests/test_hf_monitoring_routes.py -v # 8 passed
29
- ```
30
-
31
- ## Root Cause
32
- - Different test files set different auth roles at module level
33
- - `test_api.py`: teacher role
34
- - `test_video_routes.py`: was student, now teacher but client still uses admin token
35
- - `test_admin_model_routes.py`: was admin, now teacher but test setup differs
36
- - `test_hf_monitoring_routes.py`: was admin, tests need admin via separate client
37
-
38
- ## Fix Attempts
39
- 1. conftest.py - doesn't work (MagicMock doesn't reset properly with @patch)
40
- 2. Using pytest fixtures - doesn't work (@patch doesn't override MagicMock)
41
- 3. Changing module-level auth - causes different tests to fail
42
-
43
- ## Status
44
- - 177/180 tests pass when run in safe combinations
45
- - 3 tests fail only when test_video_routes runs before test_api in default order
46
- - Tests pass individually or in safe groupings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_admin_model_routes.py DELETED
@@ -1,213 +0,0 @@
1
- """
2
- Route-level tests for the /api/admin/model-config endpoints.
3
-
4
- Follows the auth mock pattern from test_api.py.
5
- """
6
-
7
- import os
8
- from unittest.mock import MagicMock, patch
9
-
10
- import pytest
11
- from fastapi.testclient import TestClient
12
-
13
- import main as main_module
14
- from main import app
15
- from services.inference_client import reset_runtime_overrides
16
-
17
- main_module._firebase_ready = True
18
- main_module._init_firebase_admin = lambda: None
19
- main_module.firebase_firestore = None
20
- main_module.firebase_auth = MagicMock()
21
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
22
- "uid": "test-teacher-uid",
23
- "email": "teacher@example.com",
24
- "role": "teacher",
25
- })
26
-
27
- admin_client = TestClient(app, headers={"Authorization": "Bearer admin-token"})
28
-
29
- _RESOLVED_KEYS = {
30
- "INFERENCE_MODEL_ID", "INFERENCE_CHAT_MODEL_ID",
31
- "HF_QUIZ_MODEL_ID", "HF_RAG_MODEL_ID", "INFERENCE_LOCK_MODEL_ID",
32
- }
33
- _KNOWN_PROFILES = {"dev", "budget", "prod"}
34
- _BASE_CONFIG_KEYS = {"profile", "overrides", "resolved"}
35
-
36
-
37
- @pytest.fixture(autouse=True)
38
- def _mock_firestore():
39
- with patch("services.inference_client._save_runtime_config_to_firestore", side_effect=None):
40
- yield
41
-
42
-
43
- @pytest.fixture(autouse=True)
44
- def _reset_overrides():
45
- reset_runtime_overrides()
46
- yield
47
- reset_runtime_overrides()
48
-
49
-
50
- # ─── Auth Enforcement ────────────────────────────────────────
51
-
52
-
53
- class TestAuth:
54
- def test_get_rejects_bad_token(self):
55
- main_module.firebase_auth.verify_id_token = MagicMock(side_effect=Exception("bad"))
56
- c = TestClient(app, headers={"Authorization": "Bearer bad-token"})
57
- response = c.get("/api/admin/model-config")
58
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
59
- "uid": "admin-uid", "email": "admin@example.com", "role": "admin",
60
- })
61
- assert response.status_code in {401, 403}
62
-
63
- def test_get_rejects_student_role(self):
64
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
65
- "uid": "student-uid", "email": "s@example.com", "role": "student",
66
- })
67
- c = TestClient(app, headers={"Authorization": "Bearer student-token"})
68
- response = c.get("/api/admin/model-config")
69
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
70
- "uid": "admin-uid", "email": "admin@example.com", "role": "admin",
71
- })
72
- assert response.status_code == 403
73
-
74
-
75
- # ─── GET Model Config ─────────────────────────────────────────
76
-
77
-
78
- class TestGetModelConfig:
79
- def test_returns_base_keys(self):
80
- response = admin_client.get("/api/admin/model-config")
81
- assert response.status_code == 200
82
- data = response.json()
83
- for key in _BASE_CONFIG_KEYS:
84
- assert key in data
85
-
86
- def test_resolved_contains_expected_keys(self):
87
- response = admin_client.get("/api/admin/model-config")
88
- data = response.json()
89
- resolved = data.get("resolved", {})
90
- for key in _RESOLVED_KEYS:
91
- assert key in resolved
92
-
93
- def test_available_profiles_present(self):
94
- response = admin_client.get("/api/admin/model-config")
95
- data = response.json()
96
- profiles = data.get("availableProfiles", [])
97
- for p in _KNOWN_PROFILES:
98
- assert p in profiles
99
-
100
- def test_profile_descriptions_present(self):
101
- response = admin_client.get("/api/admin/model-config")
102
- data = response.json()
103
- descriptions = data.get("profileDescriptions", {})
104
- for p in _KNOWN_PROFILES:
105
- assert p in descriptions
106
-
107
- def test_resolved_models_are_non_empty_strings(self):
108
- admin_client.post("/api/admin/model-config/profile", json={"profile": "dev"})
109
- response = admin_client.get("/api/admin/model-config")
110
- data = response.json()
111
- resolved = data.get("resolved", {})
112
- for key, value in resolved.items():
113
- assert isinstance(value, str), f"{key} is not a string: {value}"
114
- assert len(value) > 0, f"Resolved key {key} is empty"
115
-
116
-
117
- # ─── POST Profile Switch ─────────────────────────────────────
118
-
119
-
120
- class TestPostProfileSwitch:
121
- def test_switch_to_dev_succeeds(self):
122
- response = admin_client.post("/api/admin/model-config/profile", json={"profile": "dev"})
123
- assert response.status_code == 200
124
- assert response.json()["success"] is True
125
-
126
- def test_switch_to_budget_succeeds(self):
127
- response = admin_client.post("/api/admin/model-config/profile", json={"profile": "budget"})
128
- assert response.status_code == 200
129
- data = response.json()
130
- assert data["success"] is True
131
- assert data["applied"]["profile"] == "budget"
132
-
133
- def test_switch_to_prod_succeeds(self):
134
- response = admin_client.post("/api/admin/model-config/profile", json={"profile": "prod"})
135
- assert response.status_code == 200
136
- data = response.json()
137
- assert data["success"] is True
138
- assert data["applied"]["profile"] == "prod"
139
-
140
- def test_switch_to_invalid_profile_returns_400(self):
141
- response = admin_client.post("/api/admin/model-config/profile", json={"profile": "nonexistent"})
142
- assert response.status_code == 400
143
-
144
- def test_switch_missing_profile_field(self):
145
- response = admin_client.post("/api/admin/model-config/profile", json={})
146
- assert response.status_code == 422
147
-
148
-
149
- # ─── POST Override ───────────────────────────────────────────
150
-
151
-
152
- class TestPostOverride:
153
- def test_set_valid_override_key_succeeds(self):
154
- response = admin_client.post(
155
- "/api/admin/model-config/override",
156
- json={"key": "INFERENCE_MODEL_ID", "value": "test/override-model"},
157
- )
158
- assert response.status_code == 200
159
- assert response.json()["success"] is True
160
-
161
- def test_set_invalid_override_key_returns_400(self):
162
- response = admin_client.post(
163
- "/api/admin/model-config/override",
164
- json={"key": "EMBEDDING_MODEL", "value": "test/emb"},
165
- )
166
- assert response.status_code == 400
167
-
168
- def test_override_is_visible_in_subsequent_get(self):
169
- admin_client.post(
170
- "/api/admin/model-config/override",
171
- json={"key": "INFERENCE_MODEL_ID", "value": "custom/model-v2"},
172
- )
173
- response = admin_client.get("/api/admin/model-config")
174
- data = response.json()
175
- overrides = data.get("overrides", {})
176
- assert "INFERENCE_MODEL_ID" in overrides
177
- assert overrides["INFERENCE_MODEL_ID"] == "custom/model-v2"
178
-
179
-
180
- # ─── DELETE Reset ───────────────────────────────────────────
181
-
182
-
183
- class TestDeleteReset:
184
- def test_reset_returns_success(self):
185
- response = admin_client.delete("/api/admin/model-config/reset")
186
- assert response.status_code == 200
187
- assert response.json()["success"] is True
188
-
189
- def test_reset_clears_override(self):
190
- admin_client.post(
191
- "/api/admin/model-config/override",
192
- json={"key": "INFERENCE_MODEL_ID", "value": "temp/model"},
193
- )
194
- response = admin_client.delete("/api/admin/model-config/reset")
195
- assert response.status_code == 200
196
- overrides = response.json()["current"]["overrides"]
197
- assert overrides == {}
198
-
199
- def test_reset_clears_profile(self):
200
- admin_client.post("/api/admin/model-config/profile", json={"profile": "budget"})
201
- response = admin_client.delete("/api/admin/model-config/reset")
202
- assert response.status_code == 200
203
- assert response.json()["current"]["profile"] == ""
204
-
205
-
206
- # ─── Profile after switch ────────────────────────────────────
207
-
208
-
209
- class TestProfileAfterSwitch:
210
- def test_switched_profile_visible_in_get(self):
211
- admin_client.post("/api/admin/model-config/profile", json={"profile": "dev"})
212
- response = admin_client.get("/api/admin/model-config")
213
- assert response.json()["profile"] == "dev"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_api.py CHANGED
@@ -4,7 +4,8 @@ Comprehensive tests for all FastAPI endpoints.
4
 
5
  Tests cover:
6
  - Successful requests with valid data
7
- - AI inference API failures (502 fallback)
 
8
  - Timeout handling
9
  - Malformed response data
10
  - Error status-code mapping
@@ -84,9 +85,8 @@ mock_ae.ContentUpdatePayload = _ContentUpdatePayload
84
  mock_ae.AutomationResult = _AutomationResult
85
  sys.modules["automation_engine"] = mock_ae
86
 
87
- # Override 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,7 +97,8 @@ app = main_module.app
97
  main_module._firebase_ready = True
98
  main_module._init_firebase_admin = lambda: None
99
  main_module.firebase_firestore = None
100
- main_module.firebase_auth = MagicMock()
 
101
  main_module.firebase_auth.verify_id_token = MagicMock(
102
  return_value={
103
  "uid": "test-teacher-uid",
@@ -112,22 +113,33 @@ client = TestClient(app, headers={"Authorization": "Bearer test-auth-token"})
112
  # ─── Fixtures ──────────────────────────────────────────────────
113
 
114
 
115
- 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,36 +521,43 @@ class TestChatEndpoint:
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,15 +568,15 @@ class TestInferenceRouting:
549
  selected_model, source = client._resolve_primary_model(req)
550
  model_chain = client._model_chain_for_task("chat", selected_model)
551
 
552
- assert selected_model == "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,16 +587,16 @@ class TestInferenceRouting:
568
  selected_model, source = client._resolve_primary_model(req)
569
  model_chain = client._model_chain_for_task("chat", selected_model)
570
 
571
- assert selected_model == "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,14 +607,14 @@ class TestInferenceRouting:
588
  selected_model, source = client._resolve_primary_model(req)
589
  model_chain = client._model_chain_for_task("chat", selected_model)
590
 
591
- assert selected_model == "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,18 +625,114 @@ class TestInferenceRouting:
606
  selected_model, source = client._resolve_primary_model(req)
607
  model_chain = client._model_chain_for_task("verify_solution", selected_model)
608
 
609
- assert selected_model == "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,7 +746,7 @@ class TestRiskPrediction:
631
 
632
  def test_predict_risk_invalid_score_range(self):
633
  response = client.post("/api/predict-risk", json={
634
- "engagementScore": 150,
635
  "avgQuizScore": 75,
636
  "attendance": 90,
637
  "assignmentCompletion": 85,
@@ -653,11 +768,11 @@ class TestRiskPrediction:
653
  })
654
  assert response.status_code == 422
655
 
656
- @patch("main.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,9 +781,9 @@ class TestRiskPrediction:
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,8 +821,8 @@ class TestLearningPath:
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,14 +1180,6 @@ class TestUploadClassRecordsGuardrails:
1065
 
1066
  class TestImportedOverviewAndTopicMastery:
1067
  def test_imported_class_overview_returns_inferred_state_for_realistic_minimal_records(self):
1068
- # Ensure teacher role matches mock data
1069
- main_module.firebase_auth.verify_id_token = MagicMock(
1070
- return_value={
1071
- "uid": "test-teacher-uid",
1072
- "email": "teacher@example.com",
1073
- "role": "teacher",
1074
- }
1075
- )
1076
  firestore = _FakeFirestoreModule(
1077
  {
1078
  "normalizedClassRecords": [
@@ -1221,24 +1328,15 @@ class TestAsyncGenerationTasks:
1221
  assert cancel_payload["status"] in {"cancelled", "cancelling"}
1222
 
1223
  def test_inference_metrics_requires_admin(self):
1224
- # 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,14 +1566,6 @@ class _FakeFirestoreModule:
1468
 
1469
  class TestRecentCourseMaterials:
1470
  def test_recent_course_materials_respects_class_section_filter(self):
1471
- # Ensure teacher role matches mock data
1472
- main_module.firebase_auth.verify_id_token = MagicMock(
1473
- return_value={
1474
- "uid": "test-teacher-uid",
1475
- "email": "teacher@example.com",
1476
- "role": "teacher",
1477
- }
1478
- )
1479
  now = int(time.time())
1480
  firestore = _FakeFirestoreModule(
1481
  {
@@ -1518,14 +1608,6 @@ class TestRecentCourseMaterials:
1518
  assert all(item["classSectionId"] == "grade11_a" for item in data["materials"])
1519
 
1520
  def test_recent_course_materials_reports_retention_exclusions(self):
1521
- # Ensure teacher role matches mock data
1522
- main_module.firebase_auth.verify_id_token = MagicMock(
1523
- return_value={
1524
- "uid": "test-teacher-uid",
1525
- "email": "teacher@example.com",
1526
- "role": "teacher",
1527
- }
1528
- )
1529
  now = int(time.time())
1530
  firestore = _FakeFirestoreModule(
1531
  {
 
4
 
5
  Tests cover:
6
  - Successful requests with valid data
7
+ - Input validation errors (422)
8
+ - HuggingFace API failures (502 fallback)
9
  - Timeout handling
10
  - Malformed response data
11
  - Error status-code mapping
 
85
  mock_ae.AutomationResult = _AutomationResult
86
  sys.modules["automation_engine"] = mock_ae
87
 
88
+ # Override HF_TOKEN so client init doesn't fail
89
  os.environ["HF_TOKEN"] = "test-token-for-testing"
 
90
 
91
  # analytics.py is importable directly (its heavy deps are guarded)
92
  import main as main_module # noqa: E402
 
97
  main_module._firebase_ready = True
98
  main_module._init_firebase_admin = lambda: None
99
  main_module.firebase_firestore = None
100
+ if getattr(main_module, "firebase_auth", None) is None:
101
+ main_module.firebase_auth = MagicMock()
102
  main_module.firebase_auth.verify_id_token = MagicMock(
103
  return_value={
104
  "uid": "test-teacher-uid",
 
113
  # ─── Fixtures ──────────────────────────────────────────────────
114
 
115
 
116
+ class FakeClassificationElement:
117
+ """Mimics huggingface_hub ZeroShotClassificationOutputElement."""
118
+
119
+ def __init__(self, label: str, score: float):
120
+ self.label = label
121
+ self.score = score
122
+
123
+
124
+ def make_zsc_client(
125
+ classification: list | None = None,
126
  ):
127
+ """Create a mock InferenceClient with predictable zero-shot outputs.
128
+
129
+ Used only for risk-prediction tests (the only endpoint still using
130
+ ``get_client()`` / ``InferenceClient``).
131
+ """
132
+ mock_client = MagicMock()
133
+
134
+ if classification is None:
135
+ classification = [
136
+ FakeClassificationElement("low risk academically stable", 0.85),
137
+ FakeClassificationElement("medium academic risk", 0.10),
138
+ FakeClassificationElement("high risk of failing", 0.05),
139
+ ]
140
+ mock_client.zero_shot_classification.return_value = classification
141
+
142
+ return mock_client
143
 
144
 
145
  # ─── Health & Root ─────────────────────────────────────────────
 
521
  mock_stream_async.assert_not_called()
522
 
523
 
524
+ class TestHFChatTransport:
525
+ @patch("main.http_requests.post")
526
+ def test_call_hf_chat_uses_router_chat_completions(self, mock_post):
527
+ mock_response = MagicMock()
528
+ mock_response.status_code = 200
529
+ mock_response.json.return_value = {
530
+ "choices": [
531
+ {"message": {"content": "x = 2 or x = 3"}}
532
+ ]
533
+ }
534
+ mock_post.return_value = mock_response
535
+
536
+ result = main_module.call_hf_chat(
537
+ [{"role": "user", "content": "Solve x^2 - 5x + 6 = 0"}],
538
+ max_tokens=256,
539
+ temperature=0.2,
540
+ top_p=0.9,
541
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
543
  assert result
544
+ call_args = mock_post.call_args
545
+ endpoint = call_args.args[0]
546
+ payload = call_args.kwargs["json"]
547
+
548
+ assert endpoint == "https://router.huggingface.co/v1/chat/completions"
549
+ assert isinstance(payload["model"], str)
550
+ assert payload["model"]
551
+ assert payload["stream"] is False
552
+ assert isinstance(payload["messages"], list)
553
 
554
 
555
  class TestInferenceRouting:
556
  def test_chat_strict_model_lock_keeps_single_model_chain(self, monkeypatch):
557
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
558
  monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
559
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "true")
560
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_MODEL_ID", "meta-llama/Meta-Llama-3-70B-Instruct")
561
 
562
  client = InferenceClient()
563
  req = InferenceRequest(
 
568
  selected_model, source = client._resolve_primary_model(req)
569
  model_chain = client._model_chain_for_task("chat", selected_model)
570
 
571
+ assert selected_model == "Qwen/Qwen2.5-7B-Instruct"
572
  assert "chat_strict_model_only" in source
573
+ assert model_chain == ["Qwen/Qwen2.5-7B-Instruct"]
574
 
575
+ def test_chat_env_override_wins_under_qwen_only_lock(self, monkeypatch):
576
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen3-32B")
577
  monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
578
+ monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
579
+ monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
580
 
581
  client = InferenceClient()
582
  req = InferenceRequest(
 
587
  selected_model, source = client._resolve_primary_model(req)
588
  model_chain = client._model_chain_for_task("chat", selected_model)
589
 
590
+ assert selected_model == "Qwen/Qwen3-32B"
591
  assert "chat_override_env" in source
592
+ assert model_chain == ["Qwen/Qwen3-32B"]
593
 
594
+ def test_chat_temp_override_wins_under_qwen_only_lock(self, monkeypatch):
595
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
596
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "Qwen/Qwen3-32B")
597
  monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
598
+ monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
599
+ monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
600
 
601
  client = InferenceClient()
602
  req = InferenceRequest(
 
607
  selected_model, source = client._resolve_primary_model(req)
608
  model_chain = client._model_chain_for_task("chat", selected_model)
609
 
610
+ assert selected_model == "Qwen/Qwen3-32B"
611
  assert "chat_temp_override_env" in source
612
+ assert model_chain == ["Qwen/Qwen3-32B"]
613
 
614
+ def test_chat_temp_override_does_not_change_non_chat_task_under_qwen_lock(self, monkeypatch):
615
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_TEMP_OVERRIDE", "Qwen/Qwen3-32B")
616
+ monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
617
+ monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
618
 
619
  client = InferenceClient()
620
  req = InferenceRequest(
 
625
  selected_model, source = client._resolve_primary_model(req)
626
  model_chain = client._model_chain_for_task("verify_solution", selected_model)
627
 
628
+ assert selected_model == "Qwen/Qwen2.5-7B-Instruct"
629
  assert "chat_temp_override_env" not in source
630
+ assert model_chain == ["Qwen/Qwen2.5-7B-Instruct"]
631
+
632
+ def test_chat_escalation_when_strict_lock_disabled(self, monkeypatch):
633
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
634
+ monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "false")
635
+ monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "false")
636
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "true")
637
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_MODEL_ID", "meta-llama/Meta-Llama-3-70B-Instruct")
638
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_PROMPT_CHARS", "256")
639
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_HISTORY_CHARS", "256")
640
+
641
+ client = InferenceClient()
642
+ req = InferenceRequest(
643
+ messages=[{"role": "user", "content": "Show all steps and prove the result rigorously."}],
644
+ task_type="chat",
645
+ )
646
+
647
+ selected_model, source = client._resolve_primary_model(req)
648
+
649
+ assert selected_model == "meta-llama/Meta-Llama-3-70B-Instruct"
650
+ assert source.startswith("chat_hard_escalation:")
651
+
652
+ def test_async_chat_posts_only_qwen_when_strict_enabled(self, monkeypatch):
653
+ monkeypatch.setenv("INFERENCE_CHAT_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
654
+ monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
655
+ monkeypatch.setenv("INFERENCE_CHAT_HARD_TRIGGER_ENABLED", "true")
656
+ monkeypatch.setenv("INFERENCE_HF_TIMEOUT_SEC", "15")
657
+
658
+ routing_client = InferenceClient()
659
+ requests_seen: List[Dict[str, Any]] = []
660
+
661
+ class FakeAsyncResponse:
662
+ def __init__(self, status_code: int, payload: Dict[str, Any]):
663
+ self.status_code = status_code
664
+ self._payload = payload
665
+ self.text = json.dumps(payload)
666
+
667
+ def json(self) -> Dict[str, Any]:
668
+ return self._payload
669
+
670
+ class FakeAsyncHttpClient:
671
+ async def post(self, _url, *, headers=None, json=None, timeout=None):
672
+ requests_seen.append({
673
+ "headers": headers,
674
+ "payload": json,
675
+ "timeout": timeout,
676
+ })
677
+ return FakeAsyncResponse(
678
+ 200,
679
+ {"choices": [{"message": {"content": "Final answer: 42"}}]},
680
+ )
681
+
682
+ async def _run() -> str:
683
+ real_getenv = os.getenv
684
+
685
+ def _patched_getenv(key: str, default=None):
686
+ if key == "PYTEST_CURRENT_TEST":
687
+ return ""
688
+ return real_getenv(key, default)
689
+
690
+ with patch.object(main_module, "get_inference_client", return_value=routing_client), patch.object(
691
+ main_module,
692
+ "_get_hf_async_http_client",
693
+ new=AsyncMock(return_value=FakeAsyncHttpClient()),
694
+ ), patch.object(main_module.os, "getenv", side_effect=_patched_getenv):
695
+ return await main_module.call_hf_chat_async(
696
+ [{"role": "user", "content": "Solve x^2 - 5x + 6 = 0."}],
697
+ task_type="chat",
698
+ )
699
+
700
+ result = asyncio.run(_run())
701
+
702
+ assert "42" in result
703
+ assert len(requests_seen) == 1
704
+ sent_model = requests_seen[0]["payload"]["model"]
705
+ assert sent_model.startswith("Qwen/Qwen2.5-7B-Instruct")
706
+ assert "Meta-Llama" not in sent_model
707
+ assert "gemma" not in sent_model.lower()
708
+
709
+ def test_qwen_only_lock_replaces_explicit_non_qwen_model(self, monkeypatch):
710
+ monkeypatch.setenv("INFERENCE_ENFORCE_QWEN_ONLY", "true")
711
+ monkeypatch.setenv("INFERENCE_QWEN_LOCK_MODEL", "Qwen/Qwen2.5-7B-Instruct")
712
+ monkeypatch.setenv("INFERENCE_CHAT_STRICT_MODEL_ONLY", "true")
713
+
714
+ client = InferenceClient()
715
+ req = InferenceRequest(
716
+ messages=[{"role": "user", "content": "Solve this quickly."}],
717
+ model="meta-llama/Meta-Llama-3-70B-Instruct",
718
+ task_type="verify_solution",
719
+ )
720
+
721
+ selected_model, source = client._resolve_primary_model(req)
722
+ model_chain = client._model_chain_for_task("verify_solution", selected_model)
723
+
724
+ assert selected_model == "Qwen/Qwen2.5-7B-Instruct"
725
+ assert "qwen_only" in source
726
+ assert model_chain == ["Qwen/Qwen2.5-7B-Instruct"]
727
 
728
 
729
  # ─── Risk Prediction ──────────────────────────────────────────
730
 
731
 
732
  class TestRiskPrediction:
733
+ @patch("main.get_client")
734
+ def test_predict_risk_success(self, mock_get):
735
+ mock_get.return_value = make_zsc_client()
736
  response = client.post("/api/predict-risk", json={
737
  "engagementScore": 80,
738
  "avgQuizScore": 75,
 
746
 
747
  def test_predict_risk_invalid_score_range(self):
748
  response = client.post("/api/predict-risk", json={
749
+ "engagementScore": 150, # > 100
750
  "avgQuizScore": 75,
751
  "attendance": 90,
752
  "assignmentCompletion": 85,
 
768
  })
769
  assert response.status_code == 422
770
 
771
+ @patch("main.get_client")
772
+ def test_predict_risk_hf_failure(self, mock_get):
773
+ hf = make_zsc_client()
774
+ hf.zero_shot_classification.side_effect = Exception("HF down")
775
+ mock_get.return_value = hf
776
  response = client.post("/api/predict-risk", json={
777
  "engagementScore": 80,
778
  "avgQuizScore": 75,
 
781
  })
782
  assert response.status_code == 502
783
 
784
+ @patch("main.get_client")
785
+ def test_batch_risk_prediction(self, mock_get):
786
+ mock_get.return_value = make_zsc_client()
787
  response = client.post("/api/predict-risk/batch", json={
788
  "students": [
789
  {"engagementScore": 80, "avgQuizScore": 75, "attendance": 90, "assignmentCompletion": 85},
 
821
  assert response.status_code == 422
822
 
823
  @patch("main.call_hf_chat")
824
+ def test_learning_path_hf_failure(self, mock_chat):
825
+ mock_chat.side_effect = Exception("HF down")
826
  response = client.post("/api/learning-path", json={
827
  "weaknesses": ["algebra"],
828
  "gradeLevel": "Grade 11",
 
1180
 
1181
  class TestImportedOverviewAndTopicMastery:
1182
  def test_imported_class_overview_returns_inferred_state_for_realistic_minimal_records(self):
 
 
 
 
 
 
 
 
1183
  firestore = _FakeFirestoreModule(
1184
  {
1185
  "normalizedClassRecords": [
 
1328
  assert cancel_payload["status"] in {"cancelled", "cancelling"}
1329
 
1330
  def test_inference_metrics_requires_admin(self):
1331
+ response = client.get("/api/ops/inference-metrics")
1332
+ assert response.status_code == 403
1333
+
1334
+ @patch.object(main_module.firebase_auth, "verify_id_token", return_value={
1335
+ "uid": "admin-uid",
1336
+ "email": "admin@example.com",
1337
+ "role": "admin",
1338
+ })
1339
+ def test_inference_metrics_admin_success(self, _mock_verify):
 
 
 
 
 
 
 
 
 
1340
  response = client.get("/api/ops/inference-metrics")
1341
  assert response.status_code == 200
1342
  payload = response.json()
 
1566
 
1567
  class TestRecentCourseMaterials:
1568
  def test_recent_course_materials_respects_class_section_filter(self):
 
 
 
 
 
 
 
 
1569
  now = int(time.time())
1570
  firestore = _FakeFirestoreModule(
1571
  {
 
1608
  assert all(item["classSectionId"] == "grade11_a" for item in data["materials"])
1609
 
1610
  def test_recent_course_materials_reports_retention_exclusions(self):
 
 
 
 
 
 
 
 
1611
  now = int(time.time())
1612
  firestore = _FakeFirestoreModule(
1613
  {
tests/test_hf_monitoring_routes.py DELETED
@@ -1,148 +0,0 @@
1
- """
2
- Route-level tests for /api/hf/monitoring endpoint.
3
- Updated for DeepSeek AI monitoring.
4
- """
5
-
6
- import os
7
- from unittest.mock import MagicMock, Mock, patch
8
-
9
- import pytest
10
- from fastapi.testclient import TestClient
11
-
12
- import main as main_module
13
- from main import app
14
-
15
- main_module._firebase_ready = True
16
- main_module._init_firebase_admin = lambda: None
17
- main_module.firebase_firestore = None
18
- if getattr(main_module, "firebase_auth", None) is None:
19
- main_module.firebase_auth = MagicMock()
20
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
21
- "uid": "test-teacher-uid",
22
- "email": "teacher@example.com",
23
- "role": "teacher",
24
- })
25
-
26
- admin_client = TestClient(app, headers={"Authorization": "Bearer admin-token"})
27
-
28
- EXPECTED_MONITORING_FIELDS = {
29
- "modelId", "modelStatus", "avgResponseTimeMs",
30
- "embeddingModelId", "embeddingModelStatus",
31
- "inferenceBalance", "totalPeriodCost",
32
- "hubApiCallsUsed", "hubApiCallsLimit",
33
- "zeroGpuMinutesUsed", "zeroGpuMinutesLimit",
34
- "publicStorageUsedTB", "publicStorageLimitTB",
35
- "lastChecked", "periodStart", "periodEnd",
36
- "activeProfile", "runtimeOverridesActive", "resolvedModels",
37
- "provider", "apiBaseUrl",
38
- }
39
-
40
- EXPECTED_FIELDS_AFTER_DS_REPLACEMENT = EXPECTED_MONITORING_FIELDS
41
-
42
-
43
- @pytest.fixture(autouse=True)
44
- def _mock_env():
45
- with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "test-ds-monitoring-key"}):
46
- yield
47
-
48
-
49
- # ─── Auth Enforcement ────────────────────────────────────────
50
-
51
-
52
- class TestMonitoringAuth:
53
- def test_rejects_bad_token(self):
54
- main_module.firebase_auth.verify_id_token = MagicMock(side_effect=Exception("bad"))
55
- c = TestClient(app, headers={"Authorization": "Bearer bad-token"})
56
- response = c.get("/api/hf/monitoring")
57
- main_module.firebase_auth.verify_id_token = MagicMock(return_value={
58
- "uid": "admin-uid", "email": "admin@example.com", "role": "admin",
59
- })
60
- assert response.status_code in {401, 403}
61
-
62
-
63
- # ─── Response Shape ───────────────────────────────────────────
64
-
65
-
66
- class TestMonitoringResponseShape:
67
- @patch("main.time.time")
68
- def test_success_response_contains_all_expected_fields(self, mock_time):
69
- mock_time.return_value = 1000.0
70
-
71
- response = admin_client.get("/api/hf/monitoring")
72
- assert response.status_code == 200
73
- data = response.json()
74
- assert data["success"] is True
75
- payload = data["data"]
76
- for field in EXPECTED_FIELDS_AFTER_DS_REPLACEMENT:
77
- assert field in payload, f"Missing field: {field}"
78
-
79
- @patch("main.time.time")
80
- @patch("services.ai_client.get_deepseek_client")
81
- def test_all_probes_fail_gracefully(self, mock_ds_client_fn, mock_time):
82
- mock_time.return_value = 1000.0
83
- mock_client = MagicMock()
84
- mock_client.chat.completions.create.side_effect = Exception("network down")
85
- mock_ds_client_fn.return_value = mock_client
86
-
87
- response = admin_client.get("/api/hf/monitoring")
88
- assert response.status_code == 200
89
- data = response.json()
90
- assert data["success"] is True
91
-
92
-
93
- # ─── Response Values ──────────────────────────────────────────
94
-
95
-
96
- class TestMonitoringResponseValues:
97
- @patch("services.ai_client.get_deepseek_client")
98
- @patch("main.time.time")
99
- def test_model_status_is_degraded_when_probe_fails(self, mock_time, mock_ds_client_fn):
100
- mock_time.return_value = 1000.0
101
- mock_client = MagicMock()
102
- mock_client.chat.completions.create.side_effect = Exception("probe down")
103
- mock_ds_client_fn.return_value = mock_client
104
-
105
- response = admin_client.get("/api/hf/monitoring")
106
- data = response.json()
107
- assert data["success"] is True
108
- assert data["data"]["modelStatus"] == "Degraded"
109
-
110
- @patch("main.time.time")
111
- def test_embedding_model_id_is_returned(self, mock_time):
112
- mock_time.return_value = 1000.0
113
-
114
- response = admin_client.get("/api/hf/monitoring")
115
- data = response.json()
116
- assert data["success"] is True
117
- assert "bge-small" in data["data"]["embeddingModelId"].lower()
118
-
119
- @patch("main.time.time")
120
- def test_resolved_models_contains_task_keys(self, mock_time):
121
- mock_time.return_value = 1000.0
122
-
123
- response = admin_client.get("/api/hf/monitoring")
124
- data = response.json()
125
- resolved = data["data"].get("resolvedModels", {})
126
- expected_tasks = {"chat", "rag_lesson", "rag_problem", "quiz_generation"}
127
- for task in expected_tasks:
128
- assert task in resolved, f"Missing task: {task}"
129
- assert isinstance(resolved[task], str) and len(resolved[task]) > 0
130
-
131
- @patch("main.time.time")
132
- def test_active_profile_returned(self, mock_time):
133
- mock_time.return_value = 1000.0
134
-
135
- response = admin_client.get("/api/hf/monitoring")
136
- data = response.json()
137
- assert data["success"] is True
138
- assert data["data"]["activeProfile"] in {"dev", "budget", "prod", ""}
139
-
140
- @patch("main.time.time")
141
- def test_provider_and_api_base_url_present(self, mock_time):
142
- mock_time.return_value = 1000.0
143
-
144
- response = admin_client.get("/api/hf/monitoring")
145
- data = response.json()
146
- assert data["success"] is True
147
- assert data["data"]["provider"] == "deepseek"
148
- assert "api.deepseek.com" in data["data"]["apiBaseUrl"]