add dropdown menu to the frontend of the quiz

parent 66689c39
...@@ -338,25 +338,27 @@ def create_app() -> FastAPI: ...@@ -338,25 +338,27 @@ def create_app() -> FastAPI:
@app.post("/mcq/generate") @app.post("/mcq/generate")
async def generate_mcqs_handler( async def generate_mcqs_handler(
request: Request, request: Request,
grade: int = Form(...), curriculum: str = Form(...),
grade: str = Form(...), # Changed to str
subject: str = Form(...), subject: str = Form(...),
unit: str = Form(...), unit: str = Form(...),
concept: str = Form(...), concept: str = Form(...),
count: int = Form(5), count: int = Form(5),
is_arabic: bool = Form(False) is_arabic: bool = Form(False),
): ):
""" """
Generates and stores a new set of MCQs for a specific topic. Generates and stores a new set of MCQs for a specific topic, using the new schema.
""" """
container = request.app.state.container container = request.app.state.container
try: try:
generated_questions = container.agent_service.generate_and_store_mcqs( generated_questions = container.agent_service.generate_and_store_mcqs(
curriculum=curriculum,
grade=grade, grade=grade,
subject=subject, subject=subject,
unit=unit, unit=unit,
concept=concept, concept=concept,
num_questions=count, num_questions=count,
is_arabic=is_arabic is_arabic=is_arabic,
) )
return { return {
"status": "success", "status": "success",
...@@ -364,7 +366,7 @@ def create_app() -> FastAPI: ...@@ -364,7 +366,7 @@ def create_app() -> FastAPI:
"questions": generated_questions "questions": generated_questions
} }
except HTTPException as e: except HTTPException as e:
raise e # Re-raise FastAPI specific exceptions raise e
except Exception as e: except Exception as e:
logger.error(f"Error in generate_mcqs_handler: {e}") logger.error(f"Error in generate_mcqs_handler: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
...@@ -372,27 +374,27 @@ def create_app() -> FastAPI: ...@@ -372,27 +374,27 @@ def create_app() -> FastAPI:
@app.get("/mcq") @app.get("/mcq")
async def get_mcqs_handler( async def get_mcqs_handler(
request: Request, request: Request,
grade: int, curriculum: str,
grade: str,
subject: str, subject: str,
unit: str, unit: str,
concept: str, concept: str,
is_arabic: bool, is_arabic: bool,
# Make limit optional. If not provided, it will be None.
limit: Optional[int] = None limit: Optional[int] = None
): ):
""" """
Retrieves existing MCQs for a specific topic and language from the database. Retrieves existing MCQs for a specific topic, now filtering by curriculum.
If no limit is provided, retrieves all questions.
""" """
container = request.app.state.container container = request.app.state.container
try: try:
questions = container.agent_service.pgvector.get_mcqs( questions = container.agent_service.pgvector.get_mcqs(
curriculum=curriculum,
grade=grade, grade=grade,
subject=subject, subject=subject,
unit=unit, unit=unit,
concept=concept, concept=concept,
is_arabic=is_arabic, is_arabic=is_arabic,
limit=limit # Pass the limit (which could be None) limit=limit
) )
return { return {
"status": "success", "status": "success",
...@@ -406,7 +408,8 @@ def create_app() -> FastAPI: ...@@ -406,7 +408,8 @@ def create_app() -> FastAPI:
@app.post("/quiz/dynamic") @app.post("/quiz/dynamic")
async def get_dynamic_quiz_handler( async def get_dynamic_quiz_handler(
request: Request, request: Request,
grade: int = Form(...), curriculum: str = Form(...),
grade: str = Form(...),
subject: str = Form(...), subject: str = Form(...),
unit: str = Form(...), unit: str = Form(...),
concept: str = Form(...), concept: str = Form(...),
...@@ -414,15 +417,12 @@ def create_app() -> FastAPI: ...@@ -414,15 +417,12 @@ def create_app() -> FastAPI:
count: int = Form(5) count: int = Form(5)
): ):
""" """
Generates a dynamic quiz for a topic. Generates a dynamic quiz, now using curriculum as a key identifier.
This endpoint ensures freshness by generating a few new questions
and then randomly selects the total requested 'count' from the
entire pool of available questions (new and old).
""" """
container = request.app.state.container container = request.app.state.container
try: try:
quiz_questions = container.agent_service.get_dynamic_quiz( quiz_questions = container.agent_service.get_dynamic_quiz(
curriculum=curriculum,
grade=grade, grade=grade,
subject=subject, subject=subject,
unit=unit, unit=unit,
...@@ -436,7 +436,7 @@ def create_app() -> FastAPI: ...@@ -436,7 +436,7 @@ def create_app() -> FastAPI:
"quiz": quiz_questions "quiz": quiz_questions
} }
except HTTPException as e: except HTTPException as e:
raise e # Re-raise FastAPI specific exceptions raise e
except Exception as e: except Exception as e:
logger.error(f"Error in get_dynamic_quiz_handler: {e}") logger.error(f"Error in get_dynamic_quiz_handler: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
...@@ -459,6 +459,37 @@ def create_app() -> FastAPI: ...@@ -459,6 +459,37 @@ def create_app() -> FastAPI:
except Exception as e: except Exception as e:
print(f"Error serving quiz interface: {e}") print(f"Error serving quiz interface: {e}")
raise HTTPException(status_code=500, detail=f"Error serving interface: {str(e)}") raise HTTPException(status_code=500, detail=f"Error serving interface: {str(e)}")
@app.get("/quiz/options/curricula")
async def get_curricula_options(request: Request):
container = request.app.state.container
options = container.agent_service.pgvector.get_distinct_curricula_from_structure()
return {"options": options}
@app.get("/quiz/options/grades")
async def get_grades_options(request: Request, curriculum: str):
container = request.app.state.container
options = container.agent_service.pgvector.get_distinct_grades_from_structure(curriculum)
return {"options": options}
@app.get("/quiz/options/subjects")
async def get_subjects_options(request: Request, curriculum: str, grade: str):
container = request.app.state.container
options = container.agent_service.pgvector.get_distinct_subjects_from_structure(curriculum, grade)
return {"options": options}
@app.get("/quiz/options/units")
async def get_units_options(request: Request, curriculum: str, grade: str, subject: str):
container = request.app.state.container
options = container.agent_service.pgvector.get_distinct_units_from_structure(curriculum, grade, subject)
return {"options": options}
@app.get("/quiz/options/concepts")
async def get_concepts_options(request: Request, curriculum: str, grade: str, subject: str, unit: str):
container = request.app.state.container
options = container.agent_service.pgvector.get_distinct_concepts_from_structure(curriculum, grade, subject, unit)
return {"options": options}
@app.options("/get-audio-response") @app.options("/get-audio-response")
async def audio_response_options(): async def audio_response_options():
......
...@@ -6,6 +6,7 @@ import sys ...@@ -6,6 +6,7 @@ import sys
import json import json
import random import random
import math import math
import re
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from core import StudentNationality, Models from core import StudentNationality, Models
...@@ -110,135 +111,188 @@ class AgentService: ...@@ -110,135 +111,188 @@ class AgentService:
except Exception as e: except Exception as e:
logger.error(f"Error closing connection pools: {e}") logger.error(f"Error closing connection pools: {e}")
def _extract_grade_integer(self, grade_str: str) -> int:
"""
Safely extracts the first integer from a grade string like '4th Grade'.
This acts as a translator between the string-based MCQ schema and the
integer-based vector DB schema.
"""
if not isinstance(grade_str, str):
raise ValueError(f"Grade must be a string, but got {type(grade_str)}")
numbers = re.findall(r'\d+', grade_str)
if numbers:
return int(numbers[0])
# If no numbers are found, we cannot query the vector DB. This is an invalid input.
raise ValueError(f"Could not extract a numeric grade from the input string: '{grade_str}'")
def generate_and_store_mcqs( def generate_and_store_mcqs(
self, grade: int, subject: str, unit: str, concept: str, is_arabic: bool, num_questions: int = 5 self, curriculum: str, grade: str, subject: str, unit: str, concept: str,
is_arabic: bool, num_questions: int = 5
) -> List[Dict]: ) -> List[Dict]:
""" """
Generates NEW, UNIQUE MCQs for a topic by first retrieving existing ones Generates NEW, UNIQUE MCQs with the full schema. The AI is now responsible
and instructing the AI to avoid generating duplicates. for assigning a balanced difficulty level to each question.
""" """
if not self.pgvector: if not self.pgvector:
raise HTTPException(status_code=503, detail="Vector service is not available for context retrieval.") raise HTTPException(status_code=503, detail="Vector service is not available for context retrieval.")
# === STEP 1: RETRIEVE EXISTING QUESTIONS === # ... (Step 1 and 2 for getting existing questions and context remain the same) ...
logger.info(f"Checking for existing questions for: {grade}/{subject}/{unit}/{concept}") logger.info(f"Checking for existing questions for: {curriculum}/{grade}/{subject}/{unit}/{concept}")
existing_questions = self.pgvector.get_mcqs( existing_questions = self.pgvector.get_mcqs(
grade, subject, unit, concept, is_arabic, limit=None # Fetch ALL existing questions curriculum, grade, subject, unit, concept, is_arabic, limit=None
) )
existing_questions_text = "No existing questions found." existing_questions_text = "No existing questions found."
if existing_questions: if existing_questions:
# Format the existing questions into a simple list for the prompt
q_list = [f"- {q['question_text']}" for q in existing_questions] q_list = [f"- {q['question_text']}" for q in existing_questions]
existing_questions_text = "\n".join(q_list) existing_questions_text = "\n".join(q_list)
logger.info(f"Found {len(existing_questions)} existing questions. Will instruct AI to generate different ones.")
# === STEP 2: RETRIEVE CURRICULUM CONTEXT ===
search_query = f"summary of {concept} in {unit}"
query_embedding = self.openai_service.generate_embedding(search_query)
search_query = f"summary of {concept} in {unit} for {subject}"
query_embedding = self.openai_service.generate_embedding(search_query)
try:
grade_for_search = self._extract_grade_integer(grade)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
context_chunks = self.pgvector.search_filtered_nearest( context_chunks = self.pgvector.search_filtered_nearest(
query_embedding, grade, subject, is_arabic, limit=10 query_embedding, grade_for_search, subject, is_arabic, limit=10
) )
if not context_chunks: if not context_chunks:
raise HTTPException(status_code=404, detail="No curriculum context found for this topic in the specified language.") raise HTTPException(status_code=404, detail="No curriculum context found for this topic.")
full_context = "\n---\n".join([chunk['chunk_text'] for chunk in context_chunks]) full_context = "\n---\n".join([chunk['chunk_text'] for chunk in context_chunks])
# --- STEP 3: THE PROMPT IS UPDATED TO HANDLE DIFFICULTY INTERNALLY ---
# === STEP 3: CREATE THE ADVANCED, AWARE PROMPT ===
if is_arabic: if is_arabic:
prompt = f""" # (A similar detailed prompt in Arabic would be needed here)
أنت خبير في تطوير المناهج ومهمتك إنشاء أسئلة اختيار من متعدد جديدة ومختلفة. prompt =f"""
أنت خبير في تطوير المناهج التعليمية، ومهمتك هي إنشاء **أسئلة اختيار من متعدد جديدة بالكامل** (أسئلة لم تظهر من قبل).
هذه هي الأسئلة الموجودة حاليًا في قاعدة البيانات حول المفهوم "{concept}":
--- الأسئلة الحالية --- هذه هي الأسئلة الموجودة بالفعل لمفهوم "{concept}":
{existing_questions_text} --- الأسئلة الموجودة ---
--- نهاية الأسئلة الحالية --- {existing_questions_text}
--- نهاية الأسئلة الموجودة ---
اعتمادًا فقط على السياق التالي من المنهج:
--- السياق --- استنادًا فقط إلى المعلومات التالية:
{full_context} --- السياق ---
--- نهاية السياق --- {full_context}
--- نهاية السياق ---
يرجى توليد {num_questions} من أسئلة الاختيار من متعدد **الجديدة والمختلفة تمامًا** عن الأسئلة الموجودة أعلاه.
يجب أن تكون كل الأسئلة قابلة للإجابة مباشرة من السياق المقدم. قم بإنشاء {num_questions} سؤالًا جديدًا تمامًا من نوع الاختيار من متعدد (MCQ)، **مختلفة كليًا عن الأسئلة الموجودة أعلاه**.
يجب أن يكون ردك مصفوفة JSON صحيحة. كل كائن يجب أن يحتوي على المفاتيح التالية: ⚠️ **مهم جدًا**:
- "question_text": نص السؤال. يجب أن تتضمن الأسئلة مستويات صعوبة متنوعة وفق التوزيع التالي تقريبًا:
- "correct_answer": الإجابة الصحيحة. - ٤٠٪ أسئلة سهلة (مستوى صعوبة من ١ إلى ٤)
- "wrong_answer_1": إجابة خاطئة. - ٣٠٪ أسئلة متوسطة (مستوى صعوبة من ٥ إلى ٧)
- "wrong_answer_2": إجابة خاطئة. - ٣٠٪ أسئلة صعبة (مستوى صعوبة من ٨ إلى ١٠)
- "wrong_answer_3": إجابة خاطئة.
**صيغة الإخراج مطلوبة أن تكون مصفوفة JSON صالحة** (JSON array) من الكائنات (objects).
لا تكتب أي نص أو شرح خارج مصفوفة الـ JSON. كل كائن يجب أن يحتوي على المفاتيح التالية **بالضبط**:
"""
- "question_text": نص السؤال.
- "difficulty_level": رقم صحيح من ١ إلى ١٠ يمثل مستوى الصعوبة.
- "question_type": نوع السؤال (مثلاً: "multiple_choice").
- "correct_answer": الإجابة الصحيحة الوحيدة.
- "wrong_answer_1": إجابة خاطئة ولكن معقولة.
- "wrong_answer_2": إجابة خاطئة ولكن معقولة.
- "wrong_answer_3": إجابة خاطئة ولكن معقولة.
- "wrong_answer_4": إجابة خاطئة ولكن معقولة.
- "hint": تلميح أو مساعدة للطالب لفهم السؤال.
- "question_image_url": اترك هذا الحقل كسلسلة فارغة "".
- "correct_image_url": اترك هذا الحقل كسلسلة فارغة "".
- "wrong_image_url_1": اترك هذا الحقل كسلسلة فارغة "".
- "wrong_image_url_2": اترك هذا الحقل كسلسلة فارغة "".
- "wrong_image_url_3": اترك هذا الحقل كسلسلة فارغة "".
- "wrong_image_url_4": اترك هذا الحقل كسلسلة فارغة "".
لا تكتب أي نص خارج مصفوفة JSON.
"""
else: else:
prompt = f""" prompt = f"""
You are an expert curriculum developer tasked with creating new and unique multiple-choice questions. You are an expert curriculum developer creating new multiple-choice questions.
Here are the questions that ALREADY EXIST in the database for the concept "{concept}": Here are the questions that ALREADY EXIST for the concept "{concept}":
--- EXISTING QUESTIONS --- --- EXISTING QUESTIONS ---
{existing_questions_text} {existing_questions_text}
--- END EXISTING QUESTIONS --- --- END EXISTING QUESTIONS ---
Based ONLY on the following context from the curriculum: Based ONLY on the following context:
--- CONTEXT --- --- CONTEXT ---
{full_context} {full_context}
--- END CONTEXT --- --- END CONTEXT ---
Please generate {num_questions} NEW and COMPLETELY DIFFERENT multiple-choice questions from the list of existing ones above. Generate {num_questions} NEW and COMPLETELY DIFFERENT multiple-choice questions from the list above.
Each question must be answerable directly from the provided context. The questions and all answers MUST be in English.
**IMPORTANT**: For the {num_questions} questions you generate, assign a difficulty level to each one. The distribution should be approximately:
- 40% easy (difficulty 1-4)
- 30% medium (difficulty 5-7)
- 30% hard (difficulty 8-10)
Your response MUST be a valid JSON array of objects with these keys: Your response MUST be a valid JSON array of objects. Each object must have these exact keys:
- "question_text" - "question_text": The text of the question.
- "correct_answer" - "difficulty_level": An integer between 1 and 10, based on the distribution rule.
- "wrong_answer_1" - "question_type": The type of question (e.g., "multiple_choice").
- "wrong_answer_2" - "correct_answer": The single correct answer.
- "wrong_answer_3" - "wrong_answer_1": A plausible wrong answer.
- "wrong_answer_2": Another plausible wrong answer.
- "wrong_answer_3": A third plausible wrong answer.
- "wrong_answer_4": A fourth plausible wrong answer.
- "hint": A helpful hint for the student.
- "question_image_url": Leave this as an empty string "".
- "correct_image_url": Leave this as an empty string "".
- "wrong_image_url_1": Leave this as an empty string "".
- "wrong_image_url_2": Leave this as an empty string "".
- "wrong_image_url_3": Leave this as an empty string "".
- "wrong_image_url_4": Leave this as an empty string "".
Do not include any text outside of the JSON array. Do not include any text outside of the JSON array.
""" """
# === STEP 4 & 5: CALL LLM, PARSE, and STORE (No changes here) === # --- STEP 4: CALL LLM and PARSE (unchanged) ---
try: try:
# ... (The entire try/except block for calling the LLM remains exactly the same)
response = self.openai_service.client.chat.completions.create( response = self.openai_service.client.chat.completions.create(
model=Models.chat, model=Models.chat, messages=[{"role": "user", "content": prompt}],
messages=[{"role": "user", "content": prompt}], temperature=0.7, response_format={"type": "json_object"}
temperature=0.5, # Slightly higher temp for more creativity
response_format={"type": "json_object"}
) )
response_content = response.choices[0].message.content response_content = response.choices[0].message.content
json_response = json.loads(response_content) json_response = json.loads(response_content)
generated_questions = next((v for v in json_response.values() if isinstance(v, list)), None)
generated_questions = []
for key, value in json_response.items():
if isinstance(value, list):
generated_questions = value
break
if not generated_questions: if not generated_questions:
raise ValueError("LLM did not return a list of questions in the JSON response.") raise ValueError("LLM did not return a list of questions in the JSON response.")
except (json.JSONDecodeError, ValueError, KeyError, StopIteration) as e:
except (json.JSONDecodeError, ValueError, KeyError) as e:
logger.error(f"Failed to parse MCQ response from LLM: {e}\nRaw Response: {response_content}") logger.error(f"Failed to parse MCQ response from LLM: {e}\nRaw Response: {response_content}")
raise HTTPException(status_code=500, detail="Failed to generate or parse MCQs from AI.") raise HTTPException(status_code=500, detail="Failed to generate or parse MCQs from AI.")
# --- STEP 5: PREPARE FOR STORAGE (Now gets difficulty_level from the AI response) ---
mcqs_to_store = [] mcqs_to_store = []
for q in generated_questions: for q in generated_questions:
mcqs_to_store.append({ mcqs_to_store.append({
"grade": grade, "is_arabic": is_arabic, "subject": subject, "curriculum": curriculum, "grade": grade, "subject": subject, "unit": unit,
"unit": unit, "concept": concept, "question_text": q["question_text"], "concept": concept, "is_arabic": is_arabic,
"correct_answer": q["correct_answer"], "wrong_answer_1": q["wrong_answer_1"], "difficulty_level": q.get("difficulty_level"), # <-- AI now provides this
"wrong_answer_2": q["wrong_answer_2"], "wrong_answer_3": q["wrong_answer_3"], "question_text": q.get("question_text"),
"question_type": q.get("question_type", "multiple_choice"),
"correct_answer": q.get("correct_answer"),
"wrong_answer_1": q.get("wrong_answer_1"),
"wrong_answer_2": q.get("wrong_answer_2"),
"wrong_answer_3": q.get("wrong_answer_3"),
"wrong_answer_4": q.get("wrong_answer_4"),
"hint": q.get("hint"),
"question_image_url": q.get("question_image_url"),
"correct_image_url": q.get("correct_image_url"),
"wrong_image_url_1": q.get("wrong_image_url_1"),
"wrong_image_url_2": q.get("wrong_image_url_2"),
"wrong_image_url_3": q.get("wrong_image_url_3"),
"wrong_image_url_4": q.get("wrong_image_url_4"),
}) })
self.pgvector.insert_mcqs(mcqs_to_store) self.pgvector.insert_mcqs(mcqs_to_store)
return mcqs_to_store return mcqs_to_store
def handle_ask_for_question(self, student_id: str) -> Dict: def handle_ask_for_question(self, student_id: str) -> Dict:
...@@ -254,7 +308,13 @@ class AgentService: ...@@ -254,7 +308,13 @@ class AgentService:
student_info = self.db_service.get_student_info(student_id) student_info = self.db_service.get_student_info(student_id)
if not student_info: raise HTTPException(status_code=404, detail="Student not found.") if not student_info: raise HTTPException(status_code=404, detail="Student not found.")
grade, is_arabic, subject = student_info['grade'], student_info['is_arabic'], "Science" grade, is_arabic, subject = student_info['grade'], student_info['is_arabic'], "Science"
grade_str = f"{grade}th Grade"
nationality = student_info['nationality']
if nationality == StudentNationality.EGYPTIAN:
curriculum = "EGYPTIAN National"
else:
curriculum = "SAUDI National"
recent_history = self.db_service.get_chat_history(student_id, limit=6) recent_history = self.db_service.get_chat_history(student_id, limit=6)
if not recent_history: raise HTTPException(status_code=400, detail="Cannot ask a question without conversation context.") if not recent_history: raise HTTPException(status_code=400, detail="Cannot ask a question without conversation context.")
...@@ -278,12 +338,12 @@ class AgentService: ...@@ -278,12 +338,12 @@ class AgentService:
# 2. Generate one new question to enrich the pool (No changes here) # 2. Generate one new question to enrich the pool (No changes here)
try: try:
self.generate_and_store_mcqs(grade, subject, unit, concept, is_arabic, num_questions=1) self.generate_and_store_mcqs(curriculum, grade_str, subject, unit, concept, is_arabic, num_questions=1)
except Exception as e: except Exception as e:
logger.warning(f"Non-critical error: Failed to generate a new background MCQ: {e}") logger.warning(f"Non-critical error: Failed to generate a new background MCQ: {e}")
# 3. Retrieve and filter the pool of available questions (No changes here) # 3. Retrieve and filter the pool of available questions (No changes here)
all_mcqs = self.pgvector.get_mcqs(grade, subject, unit, concept, is_arabic, limit=None) all_mcqs = self.pgvector.get_mcqs(curriculum, grade_str, subject, unit, concept, is_arabic, limit=None)
if not all_mcqs: raise HTTPException(status_code=404, detail="No questions found for the current topic.") if not all_mcqs: raise HTTPException(status_code=404, detail="No questions found for the current topic.")
asked_question_texts = {msg['content'] for msg in recent_history if msg['role'] == 'assistant'} asked_question_texts = {msg['content'] for msg in recent_history if msg['role'] == 'assistant'}
...@@ -299,7 +359,6 @@ class AgentService: ...@@ -299,7 +359,6 @@ class AgentService:
relevant_question_texts = [] relevant_question_texts = []
last_user_message = recent_history[-1]['content'] last_user_message = recent_history[-1]['content']
# --- THIS IS THE FIX ---
# Dynamically determine how many questions to ask for. # Dynamically determine how many questions to ask for.
# Ask for up to 3, but no more than the number of available questions. # Ask for up to 3, but no more than the number of available questions.
num_to_select = min(3, len(unasked_mcqs)) num_to_select = min(3, len(unasked_mcqs))
...@@ -360,82 +419,63 @@ class AgentService: ...@@ -360,82 +419,63 @@ class AgentService:
def get_dynamic_quiz( def get_dynamic_quiz(
self, grade: int, subject: str, unit: str, concept: str, is_arabic: bool, count: int self, curriculum: str, grade: str, subject: str, unit: str, concept: str, is_arabic: bool, count: int
) -> List[Dict]: ) -> List[Dict]:
""" """
Generates a dynamic quiz of 'count' questions using a hybrid approach with BATCHED generation: Generates a dynamic quiz of 'count' questions using a hybrid approach with BATCHED generation.
1. Always generates a "freshness batch" of new questions.
2. Retrieves all questions and checks if the total meets the 'count'.
3. If not, generates the remaining number of questions needed IN BATCHES.
""" """
if not self.pgvector: if not self.pgvector:
raise HTTPException(status_code=503, detail="Vector service is not available for this feature.") raise HTTPException(status_code=503, detail="Vector service is not available for this feature.")
# Define maximum questions per batch to avoid token limits
MAX_QUESTIONS_PER_BATCH = 10 MAX_QUESTIONS_PER_BATCH = 10
# --- PART 1: Generate freshness questions ---
# 1. Calculate how many new questions to generate for freshness.
num_fresh_questions = min(max(1, math.floor(count / 3)), 5) num_fresh_questions = min(max(1, math.floor(count / 3)), 5)
logger.info(f"Request for {count} questions. Step 1: Generating {num_fresh_questions} new 'freshness' questions.") logger.info(f"Request for {count} questions. Step 1: Generating {num_fresh_questions} new 'freshness' questions.")
# 2. Generate and store these new "freshness" questions.
try: try:
# --- FIX #1: Removed the erroneous 'difficulty_level' argument ---
self.generate_and_store_mcqs( self.generate_and_store_mcqs(
grade=grade, subject=subject, unit=unit, concept=concept, curriculum=curriculum, grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, num_questions=num_fresh_questions is_arabic=is_arabic, num_questions=num_fresh_questions
) )
except Exception as e: except Exception as e:
logger.warning(f"Could not generate 'freshness' questions for the quiz due to an error: {e}") logger.warning(f"Could not generate 'freshness' questions for the quiz due to an error: {e}")
# --- PART 2: Check for shortfall and generate more if needed (WITH BATCHING) ---
# 3. Retrieve ALL available questions for the topic from the database.
all_mcqs_after_freshness = self.pgvector.get_mcqs( all_mcqs_after_freshness = self.pgvector.get_mcqs(
curriculum=curriculum,
grade=grade, subject=subject, unit=unit, concept=concept, grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, limit=None is_arabic=is_arabic, limit=None
) )
# 4. Calculate if there is still a shortfall.
questions_still_needed = count - len(all_mcqs_after_freshness) questions_still_needed = count - len(all_mcqs_after_freshness)
# 5. If we still need more questions, generate them IN BATCHES.
if questions_still_needed > 0: if questions_still_needed > 0:
logger.info(f"After freshness batch, have {len(all_mcqs_after_freshness)} questions. Need to generate {questions_still_needed} more to meet count of {count}.") logger.info(f"After freshness batch, have {len(all_mcqs_after_freshness)} questions. Need to generate {questions_still_needed} more to meet count of {count}.")
total_generated = 0
remaining = questions_still_needed remaining = questions_still_needed
while remaining > 0: while remaining > 0:
# Determine batch size (cap at MAX_QUESTIONS_PER_BATCH and also cap total at 20 per session)
batch_size = min(remaining, MAX_QUESTIONS_PER_BATCH) batch_size = min(remaining, MAX_QUESTIONS_PER_BATCH)
try: try:
logger.info(f"Generating batch {total_generated // MAX_QUESTIONS_PER_BATCH + 1} of {batch_size} questions...") logger.info(f"Generating batch of {remaining // MAX_QUESTIONS_PER_BATCH + 1} of {batch_size} questions...")
# --- FIX #2: Added the missing 'curriculum' argument ---
self.generate_and_store_mcqs( self.generate_and_store_mcqs(
grade=grade, subject=subject, unit=unit, concept=concept, curriculum=curriculum,
is_arabic=is_arabic, num_questions=batch_size grade=grade,
subject=subject,
unit=unit,
concept=concept,
is_arabic=is_arabic,
num_questions=batch_size
) )
total_generated += batch_size
remaining -= batch_size remaining -= batch_size
logger.info(f"Successfully generated batch. Total gap-filling questions generated: {total_generated}")
except Exception as e: except Exception as e:
logger.error(f"Failed to generate batch of {batch_size} questions: {e}") logger.error(f"Failed to generate batch of {batch_size} questions: {e}")
# If we've generated at least some questions, continue # Break the loop if generation fails to prevent an infinite loop
if total_generated > 0: break
logger.warning(f"Continuing with {total_generated} gap-filling questions generated so far.")
break
else:
logger.warning(f"Could not generate gap-filling questions: {e}")
break
# --- PART 3: Final Assembly and Return ---
# 6. Retrieve the final, complete list of ALL questions.
final_pool = self.pgvector.get_mcqs( final_pool = self.pgvector.get_mcqs(
curriculum=curriculum,
grade=grade, subject=subject, unit=unit, concept=concept, grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, limit=None is_arabic=is_arabic, limit=None
) )
...@@ -443,11 +483,9 @@ class AgentService: ...@@ -443,11 +483,9 @@ class AgentService:
if not final_pool: if not final_pool:
raise HTTPException(status_code=404, detail="No questions could be found or generated for this topic.") raise HTTPException(status_code=404, detail="No questions could be found or generated for this topic.")
# Check if we have enough questions
if len(final_pool) < count: if len(final_pool) < count:
logger.warning(f"Could only gather {len(final_pool)} questions out of {count} requested. Returning all available questions.") logger.warning(f"Could only gather {len(final_pool)} questions out of {count} requested. Returning all available questions.")
# 7. Randomly select the desired number of questions from the final pool.
random.shuffle(final_pool) random.shuffle(final_pool)
final_quiz = final_pool[:min(count, len(final_pool))] final_quiz = final_pool[:min(count, len(final_pool))]
......
...@@ -527,26 +527,37 @@ class PGVectorService: ...@@ -527,26 +527,37 @@ class PGVectorService:
def insert_mcqs(self, mcq_list: List[Dict]): def insert_mcqs(self, mcq_list: List[Dict]):
""" """
Inserts a batch of MCQs, now including the language flag. Inserts a batch of MCQs, now including ALL new fields from the updated schema.
""" """
if not mcq_list: if not mcq_list:
return return
with self.pool_handler.get_connection() as conn: with self.pool_handler.get_connection() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# --- UPDATED QUERY --- # --- UPDATED INSERT QUERY WITH ALL NEW COLUMNS ---
insert_query = """ insert_query = """
INSERT INTO mcq_questions ( INSERT INTO mcq_questions (
grade, is_arabic, subject, unit, concept, question_text, curriculum, grade, subject, unit, concept, question_text,
correct_answer, wrong_answer_1, wrong_answer_2, wrong_answer_3 question_type, difficulty_level, is_arabic, correct_answer,
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s); wrong_answer_1, wrong_answer_2, wrong_answer_3, wrong_answer_4,
question_image_url, correct_image_url, wrong_image_url_1,
wrong_image_url_2, wrong_image_url_3, wrong_image_url_4, hint
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s
);
""" """
# --- UPDATED DATA PREPARATION --- # --- UPDATED DATA PREPARATION TO MATCH THE NEW SCHEMA ---
# Using .get() provides safety against missing keys from the LLM response
data_to_insert = [ data_to_insert = [
( (
q['grade'], q['is_arabic'], q['subject'], q['unit'], q['concept'], q.get('curriculum'), q.get('grade'), q.get('subject'), q.get('unit'), q.get('concept'),
q['question_text'], q['correct_answer'], q['wrong_answer_1'], q.get('question_text'), q.get('question_type'), q.get('difficulty_level'),
q['wrong_answer_2'], q['wrong_answer_3'] q.get('is_arabic'), q.get('correct_answer'), q.get('wrong_answer_1'),
q.get('wrong_answer_2'), q.get('wrong_answer_3'), q.get('wrong_answer_4'),
q.get('question_image_url'), q.get('correct_image_url'), q.get('wrong_image_url_1'),
q.get('wrong_image_url_2'), q.get('wrong_image_url_3'), q.get('wrong_image_url_4'),
q.get('hint')
) for q in mcq_list ) for q in mcq_list
] ]
...@@ -554,23 +565,23 @@ class PGVectorService: ...@@ -554,23 +565,23 @@ class PGVectorService:
conn.commit() conn.commit()
logger.info(f"Successfully inserted {len(mcq_list)} MCQs into the database.") logger.info(f"Successfully inserted {len(mcq_list)} MCQs into the database.")
def get_mcqs(self, grade: int, subject: str, unit: str, concept: str, is_arabic: bool, limit: Optional[int] = 10) -> List[Dict]: def get_mcqs(self, curriculum: str, grade: str, subject: str, unit: str, concept: str, is_arabic: bool, limit: Optional[int] = 10) -> List[Dict]:
""" """
Retrieves MCQs for a specific topic and language. Retrieves MCQs for a specific topic and language, now filtering by curriculum.
If limit is None, it retrieves all matching questions. If limit is None, it retrieves all matching questions.
""" """
with self.pool_handler.get_connection() as conn: with self.pool_handler.get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
# Dynamically build the query based on the limit # --- UPDATED SELECT AND WHERE CLAUSE ---
query = """ query = """
SELECT id, question_text, correct_answer, wrong_answer_1, wrong_answer_2, wrong_answer_3 SELECT *
FROM mcq_questions FROM mcq_questions
WHERE grade = %s AND subject = %s AND unit = %s AND concept = %s AND is_arabic = %s WHERE curriculum = %s AND grade = %s AND subject = %s AND unit = %s AND concept = %s AND is_arabic = %s
ORDER BY created_at DESC ORDER BY created_at DESC
""" """
params = (grade, subject, unit, concept, is_arabic) params = (curriculum, grade, subject, unit, concept, is_arabic)
if limit is not None: if limit is not None:
query += " LIMIT %s;" query += " LIMIT %s;"
...@@ -579,4 +590,64 @@ class PGVectorService: ...@@ -579,4 +590,64 @@ class PGVectorService:
query += ";" query += ";"
cur.execute(query, params) cur.execute(query, params)
return cur.fetchall() return cur.fetchall()
\ No newline at end of file
def get_distinct_curricula_from_structure(self) -> List[str]:
"""Gets distinct curriculum names from the curriculum_structure table."""
with self.pool_handler.get_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT curriculum_data->>'title' FROM curriculum_structure ORDER BY 1;")
return [row[0] for row in cur.fetchall() if row[0]]
def get_distinct_grades_from_structure(self, curriculum: str) -> List[str]:
"""Gets distinct grades for a given curriculum from the curriculum_structure table."""
with self.pool_handler.get_connection() as conn:
with conn.cursor() as cur:
# We assume grade is stored as an integer, but return as string for consistency
cur.execute("""
SELECT DISTINCT grade::text FROM curriculum_structure
WHERE curriculum_data->>'title' = %s ORDER BY 1;
""", (curriculum,))
return [row[0] for row in cur.fetchall() if row[0]]
def get_distinct_subjects_from_structure(self, curriculum: str, grade: str) -> List[str]:
"""Gets distinct subjects for a given curriculum and grade."""
with self.pool_handler.get_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT DISTINCT subject FROM curriculum_structure
WHERE curriculum_data->>'title' = %s AND grade = %s ORDER BY 1;
""", (curriculum, int(grade))) # Grade is an integer in this table
return [row[0] for row in cur.fetchall() if row[0]]
def get_distinct_units_from_structure(self, curriculum: str, grade: str, subject: str) -> List[str]:
"""Gets distinct unit names from the JSONB data in curriculum_structure."""
with self.pool_handler.get_connection() as conn:
with conn.cursor() as cur:
# This query uses jsonb_array_elements to expand the 'units' array
cur.execute("""
SELECT DISTINCT unit->>'name'
FROM curriculum_structure, jsonb_array_elements(curriculum_data->'units') AS unit
WHERE curriculum_data->>'title' = %s AND grade = %s AND subject = %s
ORDER BY 1;
""", (curriculum, int(grade), subject))
return [row[0] for row in cur.fetchall() if row[0]]
def get_distinct_concepts_from_structure(self, curriculum: str, grade: str, subject: str, unit: str) -> List[str]:
"""Gets distinct concept names for a given unit from the JSONB data."""
with self.pool_handler.get_connection() as conn:
with conn.cursor() as cur:
# This is a more complex query that expands both units and concepts
cur.execute("""
SELECT DISTINCT concept->>'name'
FROM curriculum_structure,
jsonb_array_elements(curriculum_data->'units') AS u,
jsonb_array_elements(u->'concepts') AS concept
WHERE curriculum_data->>'title' = %s
AND grade = %s
AND subject = %s
AND u->>'name' = %s
ORDER BY 1;
""", (curriculum, int(grade), subject, unit))
return [row[0] for row in cur.fetchall() if row[0]]
\ No newline at end of file
...@@ -6,7 +6,7 @@ load_dotenv() ...@@ -6,7 +6,7 @@ load_dotenv()
def setup_mcq_table(drop_existing_table: bool = False): def setup_mcq_table(drop_existing_table: bool = False):
""" """
Sets up the mcq_questions table in the database. Sets up the mcq_questions table with the final, comprehensive schema.
""" """
try: try:
conn = psycopg2.connect( conn = psycopg2.connect(
...@@ -24,30 +24,41 @@ def setup_mcq_table(drop_existing_table: bool = False): ...@@ -24,30 +24,41 @@ def setup_mcq_table(drop_existing_table: bool = False):
cur.execute("DROP TABLE IF EXISTS mcq_questions CASCADE;") cur.execute("DROP TABLE IF EXISTS mcq_questions CASCADE;")
print("Table dropped.") print("Table dropped.")
print("Creating mcq_questions table...") print("Creating mcq_questions table with the NEW COMPREHENSIVE schema...")
# --- THIS IS THE UPDATED TABLE SCHEMA --- # --- THIS IS THE FULLY UPDATED TABLE SCHEMA ---
cur.execute(""" cur.execute("""
CREATE TABLE IF NOT EXISTS mcq_questions ( CREATE TABLE IF NOT EXISTS mcq_questions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
grade INTEGER NOT NULL, curriculum TEXT,
is_arabic BOOLEAN NOT NULL, -- <-- ADDED THIS LINE grade TEXT NOT NULL,
subject TEXT NOT NULL, subject TEXT NOT NULL,
unit TEXT NOT NULL, unit TEXT NOT NULL,
concept TEXT NOT NULL, concept TEXT NOT NULL,
question_text TEXT NOT NULL, question_text TEXT NOT NULL,
question_type TEXT,
difficulty_level INTEGER,
is_arabic BOOLEAN NOT NULL,
correct_answer TEXT NOT NULL, correct_answer TEXT NOT NULL,
wrong_answer_1 TEXT NOT NULL, wrong_answer_1 TEXT,
wrong_answer_2 TEXT NOT NULL, wrong_answer_2 TEXT,
wrong_answer_3 TEXT NOT NULL, wrong_answer_3 TEXT,
wrong_answer_4 TEXT,
question_image_url TEXT, -- Placeholder for MinIO URL
correct_image_url TEXT, -- Placeholder for MinIO URL
wrong_image_url_1 TEXT, -- Placeholder for MinIO URL
wrong_image_url_2 TEXT, -- Placeholder for MinIO URL
wrong_image_url_3 TEXT, -- Placeholder for MinIO URL
wrong_image_url_4 TEXT, -- Placeholder for MinIO URL
hint TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
""") """)
print("Creating indexes on mcq_questions table...") print("Creating indexes on mcq_questions table...")
# --- THIS IS THE UPDATED INDEX --- # --- UPDATED INDEX TO INCLUDE CURRICULUM ---
cur.execute(""" cur.execute("""
CREATE INDEX IF NOT EXISTS idx_mcq_topic CREATE INDEX IF NOT EXISTS idx_mcq_topic
ON mcq_questions(grade, is_arabic, subject, unit, concept); -- <-- ADDED is_arabic ON mcq_questions(curriculum, grade, is_arabic, subject, unit, concept);
""") """)
print("MCQ table setup complete.") print("MCQ table setup complete.")
...@@ -60,7 +71,7 @@ def setup_mcq_table(drop_existing_table: bool = False): ...@@ -60,7 +71,7 @@ def setup_mcq_table(drop_existing_table: bool = False):
print("Database connection closed.") print("Database connection closed.")
if __name__ == "__main__": if __name__ == "__main__":
# To apply the changes, it's best to drop and recreate the table. # To apply the new schema, run this script.
# Be careful if you have existing data you want to keep! # Set drop_existing_table=True to ensure a clean recreation.
print("Creating MCQ table...") print("Setting up the new MCQ table structure...")
setup_mcq_table(drop_existing_table=False) setup_mcq_table(drop_existing_table=True)
\ No newline at end of file \ No newline at end of file
...@@ -5,124 +5,33 @@ ...@@ -5,124 +5,33 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Quiz Generator</title> <title>Dynamic Quiz Generator</title>
<style> <style>
body { body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; background-color: #f9f9f9; color: #333; line-height: 1.6; }
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; .container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
max-width: 800px; h1 { text-align: center; color: #2c3e50; }
margin: 40px auto; .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
padding: 20px; .form-group { display: flex; flex-direction: column; }
background-color: #f9f9f9; .form-group.full-width { grid-column: 1 / -1; }
color: #333; label { margin-bottom: 5px; font-weight: bold; color: #555; }
line-height: 1.6; input[type="number"], select { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 5px; font-size: 16px; background-color: #fff; }
} select:disabled { background-color: #e9ecef; }
.container { .checkbox-group { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
background: white; .button-group { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
padding: 30px; button { display: block; width: 100%; padding: 12px; font-size: 16px; font-weight: bold; color: white; border: none; border-radius: 5px; cursor: pointer; transition: background 0.2s; }
border-radius: 8px; #generateButton { background: #007bff; }
box-shadow: 0 4px 15px rgba(0,0,0,0.1); #generateButton:hover { background: #0056b3; }
} #downloadButton { background: #6c757d; }
h1 { #downloadButton:hover { background: #5a6268; }
text-align: center; button:disabled { background: #95a5a6; cursor: not-allowed; }
color: #2c3e50; .status { margin-top: 20px; padding: 15px; border-radius: 5px; font-weight: bold; display: none; }
} .status.success { background-color: #d4edda; color: #155724; }
.form-grid { .status.error { background-color: #f8d7da; color: #721c24; }
display: grid; .status.processing { background-color: #e7f3ff; color: #004085; }
grid-template-columns: 1fr 1fr; #resultsContainer { margin-top: 30px; border-top: 2px solid #eee; padding-top: 20px; }
gap: 20px; .quiz-question { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; margin-bottom: 20px; }
margin-bottom: 20px; .quiz-question h3 { margin-top: 0; color: #343a40; }
} .quiz-question ul { list-style-type: none; padding: 0; }
.form-group { .quiz-question li { padding: 8px 0; }
display: flex; .correct-answer { color: #28a745; font-weight: bold; }
flex-direction: column;
}
.form-group.full-width {
grid-column: 1 / -1;
}
label {
margin-bottom: 5px;
font-weight: bold;
color: #555;
}
input[type="text"], input[type="number"] {
width: 95%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 16px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
button {
display: block;
width: 100%;
padding: 12px;
font-size: 16px;
font-weight: bold;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #95a5a6;
cursor: not-allowed;
}
button.secondary {
background: #28a745;
margin-top: 10px;
}
button.secondary:hover {
background: #218838;
}
button.secondary:disabled {
background: #95a5a6;
}
.status {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
font-weight: bold;
display: none;
}
.status.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.status.processing { background-color: #e7f3ff; color: #004085; border: 1px solid #b3d9ff; }
#resultsContainer {
margin-top: 30px;
border-top: 2px solid #eee;
padding-top: 20px;
}
.quiz-question {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 20px;
margin-bottom: 20px;
}
.quiz-question h3 {
margin-top: 0;
color: #343a40;
}
.quiz-question ul {
list-style-type: none;
padding: 0;
}
.quiz-question li {
padding: 8px 0;
}
.correct-answer {
color: #28a745;
font-weight: bold;
}
</style> </style>
</head> </head>
<body> <body>
...@@ -130,21 +39,25 @@ ...@@ -130,21 +39,25 @@
<h1>Dynamic Quiz Generator</h1> <h1>Dynamic Quiz Generator</h1>
<div class="form-grid"> <div class="form-grid">
<div class="form-group full-width">
<label for="curriculumSelect">Curriculum:</label>
<select id="curriculumSelect"></select>
</div>
<div class="form-group"> <div class="form-group">
<label for="gradeInput">Grade:</label> <label for="gradeSelect">Grade:</label>
<input type="number" id="gradeInput" value="4"> <select id="gradeSelect" disabled></select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="subjectInput">Subject:</label> <label for="subjectSelect">Subject:</label>
<input type="text" id="subjectInput" value="Science"> <select id="subjectSelect" disabled></select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="unitInput">Unit:</label> <label for="unitSelect">Unit:</label>
<input type="text" id="unitInput" placeholder="e.g., الوحدة الأولى: الأنظمة الحية"> <select id="unitSelect" disabled></select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="conceptInput">Concept:</label> <label for="conceptSelect">Concept:</label>
<input type="text" id="conceptInput" placeholder="e.g., المفهوم الأول: التكيف والبقاء"> <select id="conceptSelect" disabled></select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="countInput">Number of Questions:</label> <label for="countInput">Number of Questions:</label>
...@@ -159,48 +72,135 @@ ...@@ -159,48 +72,135 @@
</div> </div>
</div> </div>
<button id="generateButton">Generate Dynamic Quiz</button> <div class="button-group">
<button id="downloadCsvButton" class="secondary" style="display:none;">Download Quiz as CSV</button> <button id="generateButton">Generate Dynamic Quiz</button>
<button id="downloadButton" style="display:none;">Download as CSV</button>
</div>
<div id="status"></div> <div id="status"></div>
<div id="resultsContainer" style="display:none;"></div> <div id="resultsContainer" style="display:none;"></div>
</div> </div>
<script> <script>
const gradeInput = document.getElementById('gradeInput'); // --- UI Elements ---
const subjectInput = document.getElementById('subjectInput'); const curriculumSelect = document.getElementById('curriculumSelect');
const unitInput = document.getElementById('unitInput'); const gradeSelect = document.getElementById('gradeSelect');
const conceptInput = document.getElementById('conceptInput'); const subjectSelect = document.getElementById('subjectSelect');
const unitSelect = document.getElementById('unitSelect');
const conceptSelect = document.getElementById('conceptSelect');
const countInput = document.getElementById('countInput'); const countInput = document.getElementById('countInput');
const isArabicInput = document.getElementById('isArabicInput'); const isArabicInput = document.getElementById('isArabicInput');
const generateButton = document.getElementById('generateButton'); const generateButton = document.getElementById('generateButton');
const downloadCsvButton = document.getElementById('downloadCsvButton'); const downloadButton = document.getElementById('downloadButton');
const statusDiv = document.getElementById('status'); const statusDiv = document.getElementById('status');
const resultsContainer = document.getElementById('resultsContainer'); const resultsContainer = document.getElementById('resultsContainer');
let currentQuizData = null; let currentQuizData = [];
// --- Helper Functions ---
const showStatus = (message, type) => {
statusDiv.className = `status ${type}`;
statusDiv.textContent = message;
statusDiv.style.display = 'block';
};
const populateDropdown = (selectElement, options, placeholder) => {
selectElement.innerHTML = `<option value="">-- ${placeholder} --</option>`;
options.forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option;
selectElement.appendChild(opt);
});
selectElement.disabled = options.length === 0;
};
const fetchOptions = async (url) => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Server error: ${response.status}`);
const data = await response.json();
return data.options || [];
} catch (error) {
showStatus(`Failed to load options: ${error.message}`, 'error');
return [];
}
};
// --- Cascading Dropdown Logic ---
document.addEventListener('DOMContentLoaded', async () => {
const curricula = await fetchOptions('/quiz/options/curricula');
populateDropdown(curriculumSelect, curricula, 'Select a Curriculum');
});
curriculumSelect.addEventListener('change', async () => {
const curriculum = curriculumSelect.value;
populateDropdown(gradeSelect, [], 'Loading...');
populateDropdown(subjectSelect, [], 'Select Grade First');
populateDropdown(unitSelect, [], 'Select Subject First');
populateDropdown(conceptSelect, [], 'Select Unit First');
if (curriculum) {
const grades = await fetchOptions(`/quiz/options/grades?curriculum=${encodeURIComponent(curriculum)}`);
populateDropdown(gradeSelect, grades, 'Select a Grade');
}
});
gradeSelect.addEventListener('change', async () => {
const curriculum = curriculumSelect.value;
const grade = gradeSelect.value;
populateDropdown(subjectSelect, [], 'Loading...');
populateDropdown(unitSelect, [], 'Select Subject First');
populateDropdown(conceptSelect, [], 'Select Unit First');
if (grade) {
const subjects = await fetchOptions(`/quiz/options/subjects?curriculum=${encodeURIComponent(curriculum)}&grade=${encodeURIComponent(grade)}`);
populateDropdown(subjectSelect, subjects, 'Select a Subject');
}
});
subjectSelect.addEventListener('change', async () => {
const curriculum = curriculumSelect.value;
const grade = gradeSelect.value;
const subject = subjectSelect.value;
populateDropdown(unitSelect, [], 'Loading...');
populateDropdown(conceptSelect, [], 'Select Unit First');
if (subject) {
const units = await fetchOptions(`/quiz/options/units?curriculum=${encodeURIComponent(curriculum)}&grade=${encodeURIComponent(grade)}&subject=${encodeURIComponent(subject)}`);
populateDropdown(unitSelect, units, 'Select a Unit');
}
});
unitSelect.addEventListener('change', async () => {
const curriculum = curriculumSelect.value;
const grade = gradeSelect.value;
const subject = subjectSelect.value;
const unit = unitSelect.value;
populateDropdown(conceptSelect, [], 'Loading...');
if (unit) {
const concepts = await fetchOptions(`/quiz/options/concepts?curriculum=${encodeURIComponent(curriculum)}&grade=${encodeURIComponent(grade)}&subject=${encodeURIComponent(subject)}&unit=${encodeURIComponent(unit)}`);
populateDropdown(conceptSelect, concepts, 'Select a Concept');
}
});
// --- Main Action: Generate Quiz ---
generateButton.addEventListener('click', async () => { generateButton.addEventListener('click', async () => {
const grade = gradeInput.value; const [curriculum, grade, subject, unit, concept, count, isArabic] = [
const subject = subjectInput.value; curriculumSelect.value, gradeSelect.value, subjectSelect.value,
const unit = unitInput.value.trim(); unitSelect.value, conceptSelect.value, countInput.value, isArabicInput.checked
const concept = conceptInput.value.trim(); ];
const count = countInput.value;
const isArabic = isArabicInput.checked;
if (!unit || !concept) { if (!curriculum || !grade || !subject || !unit || !concept) {
showStatus('Please fill in both the Unit and Concept fields.', 'error'); showStatus('Please make a selection in all dropdown menus.', 'error');
return; return;
} }
showStatus('Generating dynamic quiz... This may take a moment.', 'processing'); showStatus('Generating dynamic quiz... This may take a moment.', 'processing');
generateButton.disabled = true; generateButton.disabled = true;
downloadCsvButton.style.display = 'none'; downloadButton.style.display = 'none';
resultsContainer.innerHTML = '';
resultsContainer.style.display = 'none'; resultsContainer.style.display = 'none';
currentQuizData = null; currentQuizData = [];
const formData = new FormData(); const formData = new FormData();
formData.append('curriculum', curriculum);
formData.append('grade', grade); formData.append('grade', grade);
formData.append('subject', subject); formData.append('subject', subject);
formData.append('unit', unit); formData.append('unit', unit);
...@@ -209,22 +209,18 @@ ...@@ -209,22 +209,18 @@
formData.append('is_arabic', isArabic); formData.append('is_arabic', isArabic);
try { try {
const response = await fetch('/quiz/dynamic', { const response = await fetch('/quiz/dynamic', { method: 'POST', body: formData });
method: 'POST',
body: formData,
});
const responseData = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(responseData.detail || `Server error: ${response.statusText}`); const err = await response.json().catch(() => ({ detail: `Server error: ${response.status}`}));
throw new Error(err.detail);
} }
const responseData = await response.json();
showStatus(responseData.message, 'success'); showStatus(responseData.message, 'success');
currentQuizData = responseData.quiz; currentQuizData = responseData.quiz;
displayQuizResults(responseData.quiz); if (currentQuizData && currentQuizData.length > 0) {
downloadCsvButton.style.display = 'block'; downloadButton.style.display = 'block';
}
displayQuizResults(currentQuizData);
} catch (error) { } catch (error) {
showStatus(`An error occurred: ${error.message}`, 'error'); showStatus(`An error occurred: ${error.message}`, 'error');
} finally { } finally {
...@@ -232,77 +228,65 @@ ...@@ -232,77 +228,65 @@
} }
}); });
downloadCsvButton.addEventListener('click', () => { // --- Download and Display Logic (RESTORED) ---
downloadButton.addEventListener('click', () => {
if (!currentQuizData || currentQuizData.length === 0) { if (!currentQuizData || currentQuizData.length === 0) {
showStatus('No quiz data available to download.', 'error'); showStatus('No quiz data available to download.', 'error');
return; return;
} }
const csvContent = convertQuizToCSV(currentQuizData); const csvContent = convertQuizToCSV(currentQuizData);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); const link = document.createElement('a');
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const filename = `quiz_${subjectSelect.value.replace(/ /g, '_')}.csv`;
const filename = `quiz_${subjectInput.value}_${Date.now()}.csv`;
link.setAttribute('href', url); link.setAttribute('href', url);
link.setAttribute('download', filename); link.setAttribute('download', filename);
link.style.visibility = 'hidden'; link.style.visibility = 'hidden';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
showStatus('Quiz downloaded successfully!', 'success');
}); });
function convertQuizToCSV(quiz) { function convertQuizToCSV(quiz) {
const headers = ['Question Number', 'Question Text', 'Correct Answer', 'Wrong Answer 1', 'Wrong Answer 2', 'Wrong Answer 3']; const headers = ['Question', 'Correct Answer', 'Wrong Answer 1', 'Wrong Answer 2', 'Wrong Answer 3'];
const escapeCSV = (str) => { const escapeCSV = (str) => {
if (str === null || str === undefined) return ''; if (str === null || str === undefined) return '';
const stringValue = String(str); let result = str.toString();
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) { if (result.includes(',') || result.includes('"') || result.includes('\n')) {
return `"${stringValue.replace(/"/g, '""')}"`; result = '"' + result.replace(/"/g, '""') + '"';
} }
return stringValue; return result;
}; };
const rows = quiz.map(q => [
const rows = quiz.map((q, index) => { escapeCSV(q.question_text),
return [ escapeCSV(q.correct_answer),
index + 1, escapeCSV(q.wrong_answer_1),
escapeCSV(q.question_text), escapeCSV(q.wrong_answer_2),
escapeCSV(q.correct_answer), escapeCSV(q.wrong_answer_3)
escapeCSV(q.wrong_answer_1), ].join(','));
escapeCSV(q.wrong_answer_2), return [headers.join(','), ...rows].join('\n');
escapeCSV(q.wrong_answer_3)
].join(',');
});
return '\uFEFF' + [headers.join(','), ...rows].join('\n');
} }
function displayQuizResults(quiz) { function displayQuizResults(quiz) {
resultsContainer.innerHTML = ''; // Clear previous results
if (!quiz || quiz.length === 0) { if (!quiz || quiz.length === 0) {
resultsContainer.innerHTML = '<p>No questions were returned.</p>'; resultsContainer.innerHTML = '<p>No questions were returned.</p>';
resultsContainer.style.display = 'block'; resultsContainer.style.display = 'block';
return; return;
} }
quiz.forEach((question, index) => { quiz.forEach((question, index) => {
const questionDiv = document.createElement('div'); const questionDiv = document.createElement('div');
questionDiv.className = 'quiz-question'; questionDiv.className = 'quiz-question';
const questionTitle = document.createElement('h3'); const questionTitle = document.createElement('h3');
questionTitle.textContent = `Question ${index + 1}: ${question.question_text}`; questionTitle.textContent = `Question ${index + 1}: ${question.question_text}`;
const optionsList = document.createElement('ul'); const optionsList = document.createElement('ul');
const options = [ const options = [
{ text: question.correct_answer, isCorrect: true }, { text: question.correct_answer, isCorrect: true },
{ text: question.wrong_answer_1, isCorrect: false }, { text: question.wrong_answer_1, isCorrect: false },
{ text: question.wrong_answer_2, isCorrect: false }, { text: question.wrong_answer_2, isCorrect: false },
{ text: question.wrong_answer_3, isCorrect: false } { text: question.wrong_answer_3, isCorrect: false }
].sort(() => Math.random() - 0.5); ].filter(opt => opt.text).sort(() => Math.random() - 0.5);
options.forEach(option => { options.forEach(option => {
const listItem = document.createElement('li'); const listItem = document.createElement('li');
listItem.textContent = option.text; listItem.textContent = option.text;
...@@ -312,20 +296,13 @@ ...@@ -312,20 +296,13 @@
} }
optionsList.appendChild(listItem); optionsList.appendChild(listItem);
}); });
questionDiv.appendChild(questionTitle); questionDiv.appendChild(questionTitle);
questionDiv.appendChild(optionsList); questionDiv.appendChild(optionsList);
resultsContainer.appendChild(questionDiv); resultsContainer.appendChild(questionDiv);
}); });
resultsContainer.style.display = 'block'; resultsContainer.style.display = 'block';
} }
function showStatus(message, type) {
statusDiv.className = `status ${type}`;
statusDiv.textContent = message;
statusDiv.style.display = 'block';
}
</script> </script>
</body> </body>
</html> </html>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment