github-actions[bot] commited on
Commit
8e2e5f4
·
1 Parent(s): 05e5f4c

🚀 Auto-deploy backend from GitHub (a17792b)

Browse files
main.py CHANGED
@@ -79,6 +79,8 @@ from services.user_provisioning_service import (
79
  )
80
  from routes.rag_routes import router as rag_router
81
  from routes.admin_model_routes import router as admin_model_router
 
 
82
  from routes.diagnostic import router as diagnostic_router
83
  from routes.video_routes import router as video_router
84
  from routes.quiz_battle import router as quiz_battle_router
@@ -372,6 +374,8 @@ ROLE_POLICIES: Dict[str, Set[str]] = {
372
  "/api/automation/data-imported": ADMIN_ONLY,
373
  "/api/automation/content-updated": ADMIN_ONLY,
374
  "/api/admin/model-config": ADMIN_ONLY,
 
 
375
  "/api/admin/model-config/profile": ADMIN_ONLY,
376
  "/api/admin/model-config/override": ADMIN_ONLY,
377
  "/api/admin/model-config/reset": ADMIN_ONLY,
@@ -1026,6 +1030,8 @@ if HAS_RATE_LIMITING and setup_rate_limiting: # type: ignore[truthy-function]
1026
 
1027
  app.include_router(rag_router)
1028
  app.include_router(admin_model_router)
 
 
1029
  app.include_router(diagnostic_router)
1030
  app.include_router(video_router)
1031
  app.include_router(quiz_battle_router)
 
79
  )
80
  from routes.rag_routes import router as rag_router
81
  from routes.admin_model_routes import router as admin_model_router
82
+ from routes.admin_routes import router as admin_pdf_router
83
+ from routes.curriculum_routes import router as curriculum_router
84
  from routes.diagnostic import router as diagnostic_router
85
  from routes.video_routes import router as video_router
86
  from routes.quiz_battle import router as quiz_battle_router
 
374
  "/api/automation/data-imported": ADMIN_ONLY,
375
  "/api/automation/content-updated": ADMIN_ONLY,
376
  "/api/admin/model-config": ADMIN_ONLY,
377
+ "/api/admin/upload-pdf": ADMIN_ONLY,
378
+ "/api/admin/reingest-pdf": ADMIN_ONLY,
379
  "/api/admin/model-config/profile": ADMIN_ONLY,
380
  "/api/admin/model-config/override": ADMIN_ONLY,
381
  "/api/admin/model-config/reset": ADMIN_ONLY,
 
1030
 
1031
  app.include_router(rag_router)
1032
  app.include_router(admin_model_router)
1033
+ app.include_router(admin_pdf_router)
1034
+ app.include_router(curriculum_router)
1035
  app.include_router(diagnostic_router)
1036
  app.include_router(video_router)
1037
  app.include_router(quiz_battle_router)
rag/firebase_storage_loader.py CHANGED
@@ -121,7 +121,7 @@ def list_curriculum_blobs(prefix: str = "curriculum/") -> List[Dict[str, str]]:
121
  # are included in the RAG pipeline.
122
 
123
  PDF_METADATA: Dict[str, dict] = {
124
- # General Mathematics — SDO Navotas teaching module (100 pages, ~125k chars)
125
  "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf": {
126
  "subject": "General Mathematics",
127
  "subjectId": "gen-math",
@@ -130,22 +130,46 @@ PDF_METADATA: Dict[str, dict] = {
130
  "quarter": 1,
131
  "storage_path": "curriculum/gen_math_sdo/SDO_Navotas_Gen.Math_SHS_1stSem.FV.pdf",
132
  },
133
- # Business Mathematics — SDO Navotas teaching module (100 pages, ~145k chars)
134
- "curriculum/business_math/SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf": {
135
- "subject": "Business Mathematics",
136
- "subjectId": "business-math",
137
  "type": "sdo_module",
138
- "content_domain": "business",
139
- "quarter": 1,
140
- "storage_path": "curriculum/business_math/SDO_Navotas_Bus.Math_SHS_1stSem.FV.pdf",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  },
142
- # Statistics and Probability — SDO Navotas teaching module (100 pages, ~156k chars)
143
- "curriculum/stat_prob/SDO_Navotas_STAT_PROB_SHS_1stSem.FV.pdf": {
144
  "subject": "Statistics and Probability",
145
  "subjectId": "stats-prob",
146
  "type": "sdo_module",
147
  "content_domain": "statistics",
148
  "quarter": 1,
149
- "storage_path": "curriculum/stat_prob/SDO_Navotas_STAT_PROB_SHS_1stSem.FV.pdf",
150
  },
151
  }
 
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",
 
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
  }
routes/admin_routes.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form, BackgroundTasks
3
+ from pydantic import BaseModel
4
+ import logging
5
+
6
+ from rag.firebase_storage_loader import _init_firebase_storage, PDF_METADATA
7
+ from scripts.ingest_from_storage import ingest_from_firebase_storage
8
+
9
+ logger = logging.getLogger("mathpulse.admin")
10
+
11
+ router = APIRouter(prefix="/api/admin", tags=["admin"])
12
+
13
+ def require_admin(request: Request):
14
+ user = getattr(request.state, "user", None)
15
+ if user is None:
16
+ raise HTTPException(status_code=401, detail="Authentication required")
17
+ if user.role != "admin":
18
+ raise HTTPException(status_code=403, detail="Admin access required")
19
+ return user
20
+
21
+ class ReingestRequest(BaseModel):
22
+ subjectId: Optional[str] = None
23
+ storagePath: Optional[str] = None
24
+
25
+ @router.post("/upload-pdf")
26
+ async def upload_pdf(
27
+ subjectId: str = Form(...),
28
+ subjectName: str = Form(...),
29
+ semester: int = Form(...),
30
+ quarter: int = Form(...),
31
+ file: UploadFile = File(...),
32
+ _admin=Depends(require_admin)
33
+ ):
34
+ if not file.filename.endswith('.pdf'):
35
+ raise HTTPException(status_code=400, detail="Only PDF files are allowed.")
36
+
37
+ file_content = await file.read()
38
+ if len(file_content) > 50 * 1024 * 1024:
39
+ raise HTTPException(status_code=400, detail="File size exceeds 50MB limit.")
40
+
41
+ _, bucket = _init_firebase_storage()
42
+ if not bucket:
43
+ raise HTTPException(status_code=500, detail="Firebase storage is not initialized.")
44
+
45
+ storage_path = f"curriculum/{subjectId}/{file.filename}"
46
+
47
+ try:
48
+ blob = bucket.blob(storage_path)
49
+ blob.upload_from_string(file_content, content_type="application/pdf")
50
+ except Exception as e:
51
+ logger.error(f"Failed to upload PDF: {e}")
52
+ raise HTTPException(status_code=500, detail=f"Failed to upload to Firebase Storage: {e}")
53
+
54
+ # Update metadata in memory before reingesting
55
+ PDF_METADATA[storage_path] = {
56
+ "subject": subjectName,
57
+ "subjectId": subjectId,
58
+ "type": "uploaded_module",
59
+ "semester": semester,
60
+ "quarter": quarter
61
+ }
62
+
63
+ # Reingest
64
+ try:
65
+ ingest_from_firebase_storage(force_reindex=True)
66
+ except Exception as e:
67
+ logger.error(f"Failed to trigger reingestion: {e}")
68
+
69
+ storage_url = f"gs://{bucket.name}/{storage_path}"
70
+ return {
71
+ "success": True,
72
+ "chunkCount": 0,
73
+ "subjectId": subjectId,
74
+ "storageUrl": storage_url
75
+ }
76
+
77
+ @router.post("/reingest-pdf")
78
+ async def reingest_pdf(
79
+ req: Optional[ReingestRequest] = None,
80
+ _admin=Depends(require_admin)
81
+ ):
82
+ try:
83
+ ingest_from_firebase_storage(force_reindex=True)
84
+ return {"success": True, "message": "Reingestion triggered successfully."}
85
+ except Exception as e:
86
+ logger.error(f"Failed to reingest: {e}")
87
+ raise HTTPException(status_code=500, detail=f"Failed to reingest: {e}")
routes/curriculum_routes.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
scripts/ingest_from_storage.py CHANGED
@@ -70,13 +70,14 @@ def _classify_lesson_section(content: str) -> str:
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
- words = text.split()
 
74
  chunks = []
75
  i = 0
76
  chunk_idx = 0
77
  while i < len(words):
78
  chunk_words = words[i : i + chunk_size]
79
- chunk_text = " ".join(chunk_words)
80
  estimated_page = max(1, (i // chunk_size) + 1)
81
  content_domain, chunk_type = _classify_chunk(chunk_text)
82
 
@@ -218,12 +219,20 @@ def ingest_from_firebase_storage(force_reindex: bool = False):
218
  logger.info(" Removed %d existing chunks", len(existing_ids))
219
 
220
  for chunk in chunks:
 
 
 
 
221
  chunk_id = f"{doc_id}_chunk_{chunk['chunk_index']}"
222
- embedding = embedder.encode(chunk["text"], normalize_embeddings=True).tolist()
 
 
 
 
223
 
224
  collection.add(
225
  embeddings=[embedding],
226
- documents=[chunk["text"]],
227
  metadatas=[{
228
  "document_id": doc_id,
229
  "module_id": metadata.get("subjectId", ""),
 
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
 
 
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", ""),
scripts/migrate_grade12_to_grade11.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Migrate Grade 12 users to Grade 11.
3
+
4
+ Run this to convert all existing Grade 12 users to Grade 11:
5
+ python backend/scripts/migrate_grade12_to_grade11.py
6
+
7
+ This handles:
8
+ - Firestore user profiles
9
+ - Progress records
10
+ - Any references to Grade 12
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def migrate_grade_12_to_grade_11():
24
+ """Migrate all Grade 12 users to Grade 11."""
25
+ try:
26
+ import firebase_admin
27
+ from firebase_admin import firestore
28
+
29
+ svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
30
+ if svc_account:
31
+ import json
32
+ from firebase_admin import credentials
33
+ creds = credentials.Certificate(json.loads(svc_account))
34
+ firebase_admin.initialize_app(creds)
35
+ else:
36
+ firebase_admin.initialize_app()
37
+
38
+ db = firestore.client()
39
+ print("Firebase initialized")
40
+
41
+ except Exception as e:
42
+ print(f"Failed to initialize Firebase: {e}")
43
+ return
44
+
45
+ # Count migrations
46
+ users_migrated = 0
47
+ progress_migrated = 0
48
+
49
+ # Migrate users collection
50
+ print("\n--- Migrating users ---")
51
+ users_ref = db.collection("users")
52
+
53
+ # Batch update for users
54
+ batch = db.batch()
55
+ user_count = 0
56
+
57
+ for doc in users_ref.stream():
58
+ data = doc.to_dict()
59
+ if data.get("grade") == "Grade 12":
60
+ batch.update(doc.reference, {"grade": "Grade 11"})
61
+ user_count += 1
62
+ print(f" Migrating user: {doc.id} ({data.get('name', 'Unknown')})")
63
+
64
+ if user_count >= 500:
65
+ batch.commit()
66
+ users_migrated += user_count
67
+ user_count = 0
68
+ batch = db.batch()
69
+
70
+ if user_count > 0:
71
+ batch.commit()
72
+ users_migrated += user_count
73
+
74
+ print(f" => Migrated {users_migrated} users to Grade 11")
75
+
76
+ # Migrate progress collection
77
+ print("\n--- Migrating progress ---")
78
+ progress_ref = db.collection("progress")
79
+ batch = db.batch()
80
+ progress_count = 0
81
+
82
+ for doc in progress_ref.stream():
83
+ data = doc.to_dict()
84
+ if data.get("gradeLevel") == "Grade 12":
85
+ batch.update(doc.reference, {"gradeLevel": "Grade 11"})
86
+ progress_count += 1
87
+
88
+ if progress_count >= 500:
89
+ batch.commit()
90
+ progress_migrated += progress_count
91
+ progress_count = 0
92
+ batch = db.batch()
93
+
94
+ if progress_count > 0:
95
+ batch.commit()
96
+ progress_migrated += progress_count
97
+
98
+ print(f" => Migrated {progress_migrated} progress records to Grade 11")
99
+
100
+ print(f"\n=== Migration complete ===")
101
+ print(f"Users migrated: {users_migrated}")
102
+ print(f"Progress migrated: {progress_migrated}")
103
+
104
+
105
+ if __name__ == "__main__":
106
+ logging.basicConfig(level=logging.INFO)
107
+ migrate_grade_12_to_grade_11()
scripts/seed_curriculum.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Seed Firestore curriculum collection from static data.
3
+
4
+ Run this ONCE to migrate static curriculum to Firestore:
5
+ python backend/scripts/seed_curriculum.py
6
+
7
+ After seeding, the curriculum API will read from Firestore.
8
+ """
9
+
10
+ import logging
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # Add backend to path
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
18
+
19
+ from services.curriculum_service import _STATIC_SUBJECTS
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def seed_curriculum():
25
+ """Seed curriculum subjects to Firestore."""
26
+ try:
27
+ import firebase_admin
28
+ from firebase_admin import firestore, credentials
29
+
30
+ # Initialize Firebase
31
+ svc_account = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON")
32
+ if svc_account:
33
+ sa_creds = credentials.Certificate(json.loads(svc_account))
34
+ firebase_admin.initialize_app(sa_creds)
35
+ else:
36
+ firebase_admin.initialize_app()
37
+
38
+ db = firestore.client()
39
+ print("Firebase initialized")
40
+
41
+ except Exception as e:
42
+ print(f"Failed to initialize Firebase: {e}")
43
+ return
44
+
45
+ # Seed new subjects
46
+ subjects_ref = db.collection("subjects")
47
+ count = 0
48
+
49
+ for subject in _STATIC_SUBJECTS:
50
+ doc_ref = subjects_ref.document(subject["id"])
51
+ doc_ref.set(subject)
52
+ count += 1
53
+ print(f" Seeded: {subject['id']} - {subject['name']} ({len(subject.get('topics', []))} topics)")
54
+
55
+ print(f"\nSeeded {count} subjects to Firestore")
56
+ print("\nCurriculum is now available at:")
57
+ print(" GET /api/curriculum/subjects")
58
+ print(" GET /api/curriculum/subjects/{id}")
59
+ print(" GET /api/curriculum/subjects/{id}/topics")
60
+
61
+
62
+ if __name__ == "__main__":
63
+ logging.basicConfig(level=logging.INFO)
64
+ seed_curriculum()
scripts/upload_lesson_modules.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Merge DepEd lesson module PDFs and upload to Firebase Storage.
3
+ Run: python backend/scripts/upload_lesson_modules.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
13
+
14
+ from pypdf import PdfWriter, PdfReader
15
+
16
+ LOCAL_MODULES_DIR = Path(__file__).resolve().parents[1].parent / "datasets" / "lesson_modules"
17
+ FIREBASE_STORAGE_BUCKET = "mathpulse-ai-2026.firebasestorage.app"
18
+
19
+ # Upload plan
20
+ UPLOAD_JOBS = [
21
+ {
22
+ "id": "basic-calc-q3",
23
+ "display_name": "Basic Calculus Q3",
24
+ "subject": "Basic Calculus",
25
+ "subjectId": "basic-calc",
26
+ "quarter": 3,
27
+ "storage_path": "curriculum/basic_calc/SDO_Navotas_BasicCalc_SHS_Q3.FV.pdf",
28
+ "local_dir": LOCAL_MODULES_DIR / "basic_calculus_q3",
29
+ "filename": "Basic Calculus-Q3-Module-{n}.pdf",
30
+ "modules": list(range(1, 9)), # Modules 1-8
31
+ },
32
+ {
33
+ "id": "gen-math-q2",
34
+ "display_name": "General Mathematics Q2",
35
+ "subject": "General Mathematics",
36
+ "subjectId": "gen-math",
37
+ "quarter": 2,
38
+ "storage_path": "curriculum/gen_math_q2/SDO_Navotas_GenMath_SHS_Q2.FV.pdf",
39
+ "local_dir": LOCAL_MODULES_DIR / "genmath_q2",
40
+ "filename": "genmath_q2_mod{n}_*.pdf",
41
+ "modules": [2, 3], # Modules 2 and 3 only
42
+ },
43
+ ]
44
+
45
+
46
+ def merge_pdfs(job: dict) -> Path | None:
47
+ """Merge multiple PDFs into a single output file. Returns output path."""
48
+ output_dir = LOCAL_MODULES_DIR / "merged"
49
+ output_dir.mkdir(parents=True, exist_ok=True)
50
+ output_path = output_dir / f"{job['id']}_merged.pdf"
51
+
52
+ writer = PdfWriter()
53
+
54
+ for mod_num in job["modules"]:
55
+ if job["id"] == "basic-calc-q3":
56
+ fname = job["filename"].format(n=mod_num)
57
+ else:
58
+ # GenMath modules have specific naming
59
+ fname = None
60
+ pattern = job["filename"].format(n=mod_num)
61
+ for f in job["local_dir"].glob(pattern):
62
+ fname = f.name
63
+ break
64
+ if fname is None:
65
+ print(f" [WARN] Could not find file for module {mod_num}")
66
+ continue
67
+
68
+ src_path = job["local_dir"] / fname
69
+ if not src_path.exists():
70
+ print(f" [WARN] File not found: {src_path}")
71
+ continue
72
+
73
+ reader = PdfReader(str(src_path))
74
+ print(f" + {src_path.name} ({len(reader.pages)} pages)")
75
+ for page in reader.pages:
76
+ writer.add_page(page)
77
+
78
+ print(f" Writing {output_path.name} ({len(writer.pages)} total pages)")
79
+ with open(output_path, "wb") as f:
80
+ writer.write(f)
81
+
82
+ return output_path
83
+
84
+
85
+ def upload_to_firebase(local_path: Path, storage_path: str) -> bool:
86
+ """Upload a PDF file to Firebase Storage."""
87
+ try:
88
+ import firebase_admin
89
+ from firebase_admin import credentials, storage
90
+ except ImportError:
91
+ print(" ERROR: firebase-admin not installed")
92
+ return False
93
+
94
+ sa_file = Path(__file__).resolve().parents[1].parent / ".secrets" / "firebase-service-account.json"
95
+ if not sa_file.exists():
96
+ print(f" ERROR: Service account not found at {sa_file}")
97
+ return False
98
+
99
+ if not firebase_admin._apps:
100
+ cred = credentials.Certificate(str(sa_file))
101
+ firebase_admin.initialize_app(cred, {"storageBucket": FIREBASE_STORAGE_BUCKET})
102
+
103
+ bucket = storage.bucket()
104
+ blob = bucket.blob(storage_path)
105
+
106
+ print(f" Uploading to gs://{bucket.name}/{storage_path}")
107
+ blob.upload_from_filename(str(local_path), content_type="application/pdf")
108
+ print(f" Upload complete!")
109
+ return True
110
+
111
+
112
+ def main():
113
+ print("=" * 60)
114
+ print("MathPulse AI — Lesson Module PDF Uploader")
115
+ print("=" * 60)
116
+
117
+ for job in UPLOAD_JOBS:
118
+ print(f"\n[{job['display_name']}]")
119
+ print("-" * 40)
120
+
121
+ # Step 1: Merge PDFs
122
+ output_path = merge_pdfs(job)
123
+ if not output_path or not output_path.exists():
124
+ print(f" [FAIL] Merge failed for {job['id']}")
125
+ continue
126
+
127
+ # Step 2: Upload to Firebase
128
+ success = upload_to_firebase(output_path, job["storage_path"])
129
+ if not success:
130
+ print(f" [FAIL] Upload failed for {job['id']}")
131
+ continue
132
+
133
+ print(f"\n SUCCESS: {job['display_name']}")
134
+ print(f" Storage path: gs://{FIREBASE_STORAGE_BUCKET}/{job['storage_path']}")
135
+ print(f" Pages: {len(PdfReader(str(output_path)).pages)}")
136
+
137
+ print("\n" + "=" * 60)
138
+ print("Done!")
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
services/curriculum_service.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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