Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
8e2e5f4
1
Parent(s): 05e5f4c
🚀 Auto-deploy backend from GitHub (a17792b)
Browse files- main.py +6 -0
- rag/firebase_storage_loader.py +35 -11
- routes/admin_routes.py +87 -0
- routes/curriculum_routes.py +66 -0
- scripts/ingest_from_storage.py +13 -4
- scripts/migrate_grade12_to_grade11.py +107 -0
- scripts/seed_curriculum.py +64 -0
- scripts/upload_lesson_modules.py +142 -0
- services/curriculum_service.py +232 -0
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, ~
|
| 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 |
-
#
|
| 134 |
-
"curriculum/
|
| 135 |
-
"subject": "
|
| 136 |
-
"subjectId": "
|
| 137 |
"type": "sdo_module",
|
| 138 |
-
"content_domain": "
|
| 139 |
-
"quarter":
|
| 140 |
-
"storage_path": "curriculum/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
},
|
| 142 |
-
# Statistics and Probability —
|
| 143 |
-
"curriculum/stat_prob/
|
| 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/
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
collection.add(
|
| 225 |
embeddings=[embedding],
|
| 226 |
-
documents=[
|
| 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
|