Commit 13ca82ce authored by salma's avatar salma

add test yourself and multiplayer test features

parent 56712e0e
...@@ -262,7 +262,7 @@ if __name__ == "__main__": ...@@ -262,7 +262,7 @@ if __name__ == "__main__":
json_file_path = "All_Curriculums_grouped.json" json_file_path = "All_Curriculums_grouped.json"
# Setup curriculum database with JSON data # Setup curriculum database with JSON data
setup_curriculum_database(json_file_path, drop_existing_table=True) setup_curriculum_database(json_file_path, drop_existing_table=False)
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("🔍 Verifying Setup") print("🔍 Verifying Setup")
......
import os import os
import uuid
import asyncio
from fastapi import WebSocket, WebSocketDisconnect, Depends
from typing import List, Dict
import shutil import shutil
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, BackgroundTasks, logger from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, BackgroundTasks, logger
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
...@@ -13,8 +17,7 @@ import tempfile ...@@ -13,8 +17,7 @@ import tempfile
import json import json
import pandas as pd import pandas as pd
import logging import logging
from curriculum_structure import convert_json_to_db_format from process_pdf_pipline import process_pdf_curriculum_in_background
from process_pdf_pipline import run_full_pipeline
# Import your existing modules # Import your existing modules
from core import AppConfig from core import AppConfig
...@@ -22,12 +25,14 @@ from repositories import MinIOStorageRepository ...@@ -22,12 +25,14 @@ from repositories import MinIOStorageRepository
from services import ( from services import (
AudioService, ChatService, HealthService, ResponseService, AudioService, ChatService, HealthService, ResponseService,
ResponseManager, OpenAIService, AgentService, ConnectionPool, LanguageSegmentationService, ResponseManager, OpenAIService, AgentService, ConnectionPool, LanguageSegmentationService,
DataIngestionService DataIngestionService, WebSocketManager, redis_client, redis_listener, get_room_key, get_room_channel
) )
from utils import DateTimeEncoder
from schemas.mcq import QuestionResponse, QuizResponse, MCQListResponse from schemas.mcq import QuestionResponse, QuizResponse, MCQListResponse, QuizSubmission
# Instantiate one manager per worker
manager = WebSocketManager()
class DIContainer: class DIContainer:
def __init__(self): def __init__(self):
...@@ -66,36 +71,43 @@ class DIContainer: ...@@ -66,36 +71,43 @@ class DIContainer:
self.health_service = HealthService(self.storage_repo, self.config) self.health_service = HealthService(self.storage_repo, self.config)
def run_processing_pipeline(pdf_path: str, grade: int, subject: str) -> tuple[str, str]:
"""
Runs the full PDF processing pipeline and returns paths to the generated CSV and JSON files.
"""
temp_json_path = "temp_json.json"
temp_csv_path = "temp_embeddings.csv"
run_full_pipeline(pdf_path, grade, subject, temp_json_path, temp_csv_path, remove_lessons=True)
return temp_csv_path, temp_json_path
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
""" """
Manages application startup and shutdown events for resource safety. Manages application startup and shutdown events.
We'll start the Redis listener here.
""" """
# --- Code to run ON STARTUP --- # --- Code to run ON STARTUP ---
print("Application starting up...") print("Application starting up...")
container = DIContainer() container = DIContainer()
app.state.container = container app.state.container = container
print("DIContainer created and database pool initialized.") print("DIContainer created.")
# Start the background Redis listener task
if redis_client:
listener_task = asyncio.create_task(redis_listener(manager))
app.state.redis_listener_task = listener_task
else:
app.state.redis_listener_task = None
print("WARNING: Redis not connected. Live Quiz feature will not work across multiple workers.")
yield # The application is now running and handling requests yield # The application is now running
# --- Code to run ON SHUTDOWN --- # --- Code to run ON SHUTDOWN ---
print("Application shutting down...") print("Application shutting down...")
# This is the guaranteed, graceful shutdown call if app.state.redis_listener_task:
app.state.redis_listener_task.cancel()
await app.state.redis_listener_task
app.state.container.agent_service.close() app.state.container.agent_service.close()
print("Database connection pool closed successfully.") print("Database connection pool closed successfully.")
def create_app() -> FastAPI: def create_app() -> FastAPI:
# Connect the lifespan manager to your FastAPI app instance # Connect the lifespan manager to your FastAPI app instance
app = FastAPI(title="Unified Chat API with Local Agent", lifespan=lifespan) app = FastAPI(title="Unified Chat API with Local Agent", lifespan=lifespan)
...@@ -125,111 +137,6 @@ def create_app() -> FastAPI: ...@@ -125,111 +137,6 @@ def create_app() -> FastAPI:
) )
def process_pdf_curriculum_in_background(pdf_bytes: bytes, original_filename: str, grade: int, subject: str):
"""
Background task to process uploaded curriculum PDF.
This function runs asynchronously and won't block the API response.
"""
print(f"--- Background task started: Processing PDF '{original_filename}'. ---", flush=True)
pool_handler = None
try:
# --- Setup Paths ---
project_root = Path(__file__).parent
embeddings_dir = project_root / "embeddings"
main_json_path = project_root / "All_Curriculums_grouped.json"
embeddings_dir.mkdir(exist_ok=True)
# --- Create Dependencies ---
pool_handler = ConnectionPool(
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
host=os.getenv("DB_HOST", "postgres"),
port=int(os.getenv("DB_PORT", 5432))
)
ingestion_service = DataIngestionService(pool_handler=pool_handler)
# --- 1. Save and Run Pipeline ---
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_pdf:
temp_pdf.write(pdf_bytes)
temp_pdf_path = temp_pdf.name
print(f"--- Background task: Saved temp PDF to {temp_pdf_path} ---", flush=True)
temp_csv_path, temp_json_path = run_processing_pipeline(temp_pdf_path, grade, subject)
# --- 2. Save the generated CSV ---
csv_filename = Path(temp_csv_path).name
csv_dest_path = embeddings_dir / csv_filename
shutil.move(temp_csv_path, csv_dest_path)
print(f"--- Background task: Saved new embeddings to '{csv_dest_path}' ---", flush=True)
# --- 3. Read both JSON files ---
print("--- Background task: Reading generated JSON structure... ---", flush=True)
with open(temp_json_path, 'r', encoding='utf-8') as f:
new_structure_data = json.load(f)
print(f"--- Background task: New structure contains keys: {list(new_structure_data.keys())} ---", flush=True)
# Load existing main JSON or start with empty dict
try:
with open(main_json_path, 'r', encoding='utf-8') as f:
existing_structure_data = json.load(f)
print(f"--- Background task: Loaded existing structure with {len(existing_structure_data)} curricula ---", flush=True)
except FileNotFoundError:
print("--- Background task: Main JSON file not found. Creating new one. ---", flush=True)
existing_structure_data = {}
except json.JSONDecodeError:
print("--- Background task: Main JSON file corrupted. Starting fresh. ---", flush=True)
existing_structure_data = {}
# Append new curriculum keys to the existing structure
for curriculum_key, curriculum_content in new_structure_data.items():
if curriculum_key in existing_structure_data:
print(f"--- WARNING: Key '{curriculum_key}' already exists. Overwriting. ---", flush=True)
else:
print(f"--- Background task: Adding new curriculum '{curriculum_key}' to main JSON. ---", flush=True)
existing_structure_data[curriculum_key] = curriculum_content
# Write the updated data back to the file
with open(main_json_path, 'w', encoding='utf-8') as f:
json.dump(existing_structure_data, f, indent=2, ensure_ascii=False)
print(f"--- Background task: Main JSON now contains {len(existing_structure_data)} curricula ---", flush=True)
# ==========================================================
# --- 4. Ingest structure into DB ---
print("--- Background task: Ingesting new structure into DB... ---", flush=True)
db_formatted_structure = convert_json_to_db_format(new_structure_data)
ingestion_service.ingest_curriculum_structure(db_formatted_structure)
# --- 5. Ingest embeddings into DB ---
print("--- Background task: Ingesting new embeddings into DB... ---", flush=True)
embeddings_df = pd.read_csv(csv_dest_path)
ingestion_service.ingest_embeddings_from_csv(embeddings_df)
print("--- Background task: Verifying database insertions... ---", flush=True)
from services.pgvector_service import PGVectorService
pgvector_service = PGVectorService(pool_handler)
pgvector_service.verify_recent_insertions()
# --- 6. Cleanup ---
os.unlink(temp_pdf_path)
os.unlink(temp_json_path)
print("--- Background task: Cleaned up temporary files ---", flush=True)
print("--- ✅ Background task completed successfully. ---", flush=True)
except Exception as e:
import traceback
print(f"--- ❌ FATAL ERROR in background task: {e} ---", flush=True)
print(f"--- Traceback: {traceback.format_exc()} ---", flush=True)
finally:
if pool_handler:
pool_handler.close_all()
print("--- Background task: Database connection pool closed. ---", flush=True)
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
...@@ -471,6 +378,62 @@ def create_app() -> FastAPI: ...@@ -471,6 +378,62 @@ def create_app() -> FastAPI:
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.post("/quiz/grade")
async def grade_quiz_handler(submission: QuizSubmission):
"""
Receives a quiz submission, grades it, and returns the results.
"""
correct_answers_count = 0
results = []
# Create a simple lookup map for correct answers from the full question objects
correct_answer_map = {q['question_text']: q['correct_answer'] for q in submission.questions}
for question_text, user_answer in submission.answers.items():
correct_answer = correct_answer_map.get(question_text)
is_correct = (user_answer == correct_answer)
if is_correct:
correct_answers_count += 1
results.append({
"question_text": question_text,
"user_answer": user_answer,
"correct_answer": correct_answer,
"is_correct": is_correct
})
total_questions = len(submission.questions)
percentage = (correct_answers_count / total_questions) * 100 if total_questions > 0 else 0
return {
"status": "success",
"score": correct_answers_count,
"total_questions": total_questions,
"percentage": round(percentage, 2),
"results": results
}
@app.get("/test-yourself")
async def serve_test_yourself_interface():
"""Serve the interactive 'Test Yourself' HTML file"""
try:
# Check for the file in a 'static' folder first
static_file = Path("static/test_yourself_interface.html")
if static_file.exists():
return FileResponse(static_file)
# Fallback to the root directory
current_file = Path("test_yourself_interface.html")
if current_file.exists():
return FileResponse(current_file)
raise HTTPException(status_code=404, detail="Interactive quiz interface not found")
except Exception as e:
print(f"Error serving 'Test Yourself' interface: {e}")
raise HTTPException(status_code=500, detail=f"Error serving interface: {str(e)}")
@app.get("/quiz/options/curricula") @app.get("/quiz/options/curricula")
async def get_curricula_options(request: Request): async def get_curricula_options(request: Request):
container = request.app.state.container container = request.app.state.container
...@@ -500,8 +463,242 @@ def create_app() -> FastAPI: ...@@ -500,8 +463,242 @@ def create_app() -> FastAPI:
container = request.app.state.container container = request.app.state.container
options = container.agent_service.pgvector.get_distinct_concepts_from_structure(curriculum, grade, subject, unit) options = container.agent_service.pgvector.get_distinct_concepts_from_structure(curriculum, grade, subject, unit)
return {"options": options} return {"options": options}
@app.post("/quiz/room/create")
async def create_quiz_room(
request: Request,
curriculum: str = Form(...),
grade: str = Form(...),
subject: str = Form(...),
unit: str = Form(...),
concept: str = Form(...),
is_arabic: bool = Form(...),
count: int = Form(5),
host_id: str = Form(...)
):
if not redis_client:
raise HTTPException(status_code=503, detail="Service unavailable: Redis connection is not configured.")
container = request.app.state.container
try:
quiz_questions = container.agent_service.get_dynamic_quiz(
curriculum=curriculum, grade=grade, subject=subject,
unit=unit, concept=concept, is_arabic=is_arabic, count=count
)
if not quiz_questions:
raise HTTPException(status_code=404, detail="Could not generate questions for this topic.")
room_id = str(uuid.uuid4())[:6].upper()
room_key = get_room_key(room_id)
print(f"Creating room with ID: {room_id}")
print(f"Room key: {room_key}")
room_state = {
"status": "lobby",
"host_id": host_id,
"quiz_questions": json.dumps(quiz_questions, cls=DateTimeEncoder),
"participants": json.dumps({}),
"results": json.dumps([])
}
redis_client.hset(room_key, mapping=room_state)
redis_client.expire(room_key, 7200)
# VERIFY it was created
verify_exists = redis_client.exists(room_key)
print(f"Room created and verified: {verify_exists}")
return {"status": "success", "room_id": room_id}
except Exception as e:
logger.error(f"Error creating quiz room: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@app.get("/quiz/room/{room_id}")
async def get_room_status(room_id: str):
print(f"=== GET /quiz/room/{room_id} called ===")
room_key = get_room_key(room_id)
print(f"Room key: {room_key}")
if not redis_client:
print("ERROR: Redis client is None")
raise HTTPException(status_code=503, detail="Service unavailable: Redis connection is not configured.")
try:
# Try to ping Redis first
redis_client.ping()
print("Redis ping successful")
except Exception as e:
print(f"Redis ping failed: {e}")
raise HTTPException(status_code=503, detail="Redis connection failed")
exists = redis_client.exists(room_key)
print(f"redis_client.exists('{room_key}') returned: {exists}")
if not exists:
# Show what keys DO exist
try:
all_keys = redis_client.keys("quiz_room:*")
print(f"All quiz room keys in Redis: {all_keys}")
except Exception as e:
print(f"Failed to list keys: {e}")
raise HTTPException(status_code=404, detail="Room not found.")
room_status = redis_client.hget(room_key, "status")
print(f"Room status retrieved: {room_status}")
return {"status": "exists", "room_status": room_status}
@app.websocket("/ws/quiz/room/{room_id}/{student_id}")
async def websocket_endpoint(websocket: WebSocket, room_id: str, student_id: str):
room_key = get_room_key(room_id)
room_channel = get_room_channel(room_id)
print(f"WebSocket connection attempt - Room: {room_id}, Student: {student_id}")
# IMPORTANT: Accept connection first
await manager.connect(websocket, room_id)
# Then check validity
if not redis_client:
print("ERROR: Redis client not available!")
await websocket.close(code=1003, reason="Redis not available")
manager.disconnect(websocket, room_id)
return
room_exists = redis_client.exists(room_key)
print(f"Room {room_id} exists: {room_exists}")
if not room_exists:
print(f"ERROR: Room {room_id} not found in Redis!")
await websocket.close(code=1008, reason="Room not found")
manager.disconnect(websocket, room_id)
return
container = websocket.app.state.container
pipe = redis_client.pipeline()
try:
# 1. Handle new student joining
student_info = container.agent_service.db_service.get_student_info(student_id)
student_name = student_info['student_name'] if student_info else "Unknown Student"
# Atomically get and update participants
room_data = redis_client.hgetall(room_key)
participants = json.loads(room_data.get("participants", "{}"))
participants[student_id] = {"name": student_name, "status": "connected"}
pipe.hset(room_key, "participants", json.dumps(participants))
pipe.execute()
print(f"Student {student_id} joined room {room_id}. Publishing participant_update...")
# Broadcast the update via Redis Pub/Sub
redis_client.publish(room_channel, json.dumps({
"type": "participant_update",
"participants": participants,
"host_id": room_data.get("host_id")
}))
print(f"Published participant_update to channel: {room_channel}")
# Main loop to listen for messages from this client
while True:
data = await websocket.receive_json()
message_type = data.get("type")
print(f"Received {message_type} from {student_id} in room {room_id}")
# Use HGETALL to get the latest state before updating
current_room_data = redis_client.hgetall(room_key)
host_id = current_room_data.get("host_id")
if message_type == "start_quiz" and student_id == host_id:
pipe.hset(room_key, "status", "in_progress")
pipe.execute()
redis_client.publish(room_channel, json.dumps({
"type": "quiz_started",
"questions": json.loads(current_room_data.get("quiz_questions", "[]"))
}))
print(f"Published quiz_started to channel: {room_channel}")
elif message_type == "submit_answers":
user_answers = data.get("answers", {})
time_taken = data.get("time_seconds", 0)
questions = json.loads(current_room_data.get("quiz_questions", "[]"))
results = json.loads(current_room_data.get("results", "[]"))
participants = json.loads(current_room_data.get("participants", "{}"))
score = 0
correct_answers = {q['question_text']: q['correct_answer'] for q in questions}
for q_text, u_answer in user_answers.items():
if correct_answers.get(q_text) == u_answer:
score += 1
results.append({"student_id": student_id, "name": student_name, "score": score, "time_seconds": time_taken})
results.sort(key=lambda x: (-x['score'], x['time_seconds']))
participants[student_id]["status"] = "finished"
all_finished = all(p["status"] == "finished" for p in participants.values())
if all_finished:
pipe.hset(room_key, "status", "finished")
pipe.hset(room_key, "results", json.dumps(results))
pipe.hset(room_key, "participants", json.dumps(participants))
pipe.execute()
redis_client.publish(room_channel, json.dumps({
"type": "results_update", "results": results, "is_final": all_finished
}))
except WebSocketDisconnect:
print(f"Student {student_id} disconnected from room {room_id}")
# Handle student leaving
current_participants = json.loads(redis_client.hget(room_key, "participants") or "{}")
if student_id in current_participants:
del current_participants[student_id]
redis_client.hset(room_key, "participants", json.dumps(current_participants))
# Get the host_id before broadcasting
room_data = redis_client.hgetall(room_key)
redis_client.publish(room_channel, json.dumps({
"type": "participant_update",
"participants": current_participants,
"host_id": room_data.get("host_id")
}))
except Exception as e:
print(f"WebSocket error for {student_id} in room {room_id}: {e}")
finally:
manager.disconnect(websocket, room_id)
print(f"Cleaned up connection for {student_id} in room {room_id}")
@app.get("/live-quiz")
async def serve_live_quiz_interface():
"""Serve the live quiz HTML file"""
try:
# Check for the file in a 'static' folder first
static_file = Path("static/live_quiz_interface.html")
if static_file.exists():
return FileResponse(static_file)
# Fallback to the root directory
current_file = Path("live_quiz_interface.html")
if current_file.exists():
return FileResponse(current_file)
raise HTTPException(status_code=404, detail="Live quiz interface not found")
except Exception as e:
logger.error(f"Error serving live quiz interface: {e}")
raise HTTPException(status_code=500, detail=f"Error serving interface: {str(e)}")
@app.options("/get-audio-response") @app.options("/get-audio-response")
async def audio_response_options(): async def audio_response_options():
"""Handle preflight CORS requests for audio response endpoint""" """Handle preflight CORS requests for audio response endpoint"""
...@@ -597,10 +794,28 @@ def create_app() -> FastAPI: ...@@ -597,10 +794,28 @@ def create_app() -> FastAPI:
return Response(content=b"test audio data", media_type="audio/mpeg", headers={"X-Response-Text": encoded_text, "Access-Control-Expose-Headers": "X-Response-Text"}) return Response(content=b"test audio data", media_type="audio/mpeg", headers={"X-Response-Text": encoded_text, "Access-Control-Expose-Headers": "X-Response-Text"})
@app.get("/") @app.get("/")
async def serve_index():
"""Serves the main navigation hub HTML file."""
try:
# Check for the file in a 'static' folder first
static_file = Path("static/index.html")
if static_file.exists():
return FileResponse(static_file)
# Fallback to the root directory
current_file = Path("index.html")
if current_file.exists():
return FileResponse(current_file)
raise HTTPException(status_code=404, detail="Index page not found")
except Exception as e:
logger.error(f"Error serving index page: {e}")
raise HTTPException(status_code=500, detail=f"Error serving interface: {str(e)}")
@app.get("/api-info")
async def root(): async def root():
"""Root endpoint with API info""" """Root endpoint with API info"""
return {"service": "Unified Chat API with Local Agent", "version": "2.2.0-lifespan", "status": "running"} return {"service": "Unified Chat API with Local Agent", "version": "2.2.0-lifespan", "status": "running"}
return app return app
# Application entry point # Application entry point
......
...@@ -907,4 +907,135 @@ def run_full_pipeline(pdf_path: str, grade: int, subject: str, output_json_path: ...@@ -907,4 +907,135 @@ def run_full_pipeline(pdf_path: str, grade: int, subject: str, output_json_path:
except Exception as e: except Exception as e:
logging.critical(f"Pipeline error: {e}", exc_info=True) logging.critical(f"Pipeline error: {e}", exc_info=True)
logging.info(f"\n--- Pipeline finished for {pdf_path} ---") logging.info(f"\n--- Pipeline finished for {pdf_path} ---")
\ No newline at end of file
def run_processing_pipeline(pdf_path: str, grade: int, subject: str) -> tuple[str, str]:
"""
Runs the full PDF processing pipeline and returns paths to the generated CSV and JSON files.
"""
temp_json_path = "temp_json.json"
temp_csv_path = "temp_embeddings.csv"
run_full_pipeline(pdf_path, grade, subject, temp_json_path, temp_csv_path, remove_lessons=True)
return temp_csv_path, temp_json_path
from fastapi import BackgroundTasks
import os
import shutil
import tempfile
from pathlib import Path
import json
import pandas as pd
from services import DataIngestionService
from services import ConnectionPool
from curriculum_structure import convert_json_to_db_format
def process_pdf_curriculum_in_background(pdf_bytes: bytes, original_filename: str, grade: int, subject: str):
"""
Background task to process uploaded curriculum PDF.
This function runs asynchronously and won't block the API response.
"""
print(f"--- Background task started: Processing PDF '{original_filename}'. ---", flush=True)
pool_handler = None
try:
# --- Setup Paths ---
project_root = Path(__file__).parent
embeddings_dir = project_root / "embeddings"
main_json_path = project_root / "All_Curriculums_grouped.json"
embeddings_dir.mkdir(exist_ok=True)
# --- Create Dependencies ---
pool_handler = ConnectionPool(
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
host=os.getenv("DB_HOST", "postgres"),
port=int(os.getenv("DB_PORT", 5432))
)
ingestion_service = DataIngestionService(pool_handler=pool_handler)
# --- 1. Save and Run Pipeline ---
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_pdf:
temp_pdf.write(pdf_bytes)
temp_pdf_path = temp_pdf.name
print(f"--- Background task: Saved temp PDF to {temp_pdf_path} ---", flush=True)
temp_csv_path, temp_json_path = run_processing_pipeline(temp_pdf_path, grade, subject)
# --- 2. Save the generated CSV ---
csv_filename = Path(temp_csv_path).name
csv_dest_path = embeddings_dir / csv_filename
shutil.move(temp_csv_path, csv_dest_path)
print(f"--- Background task: Saved new embeddings to '{csv_dest_path}' ---", flush=True)
# --- 3. Read both JSON files ---
print("--- Background task: Reading generated JSON structure... ---", flush=True)
with open(temp_json_path, 'r', encoding='utf-8') as f:
new_structure_data = json.load(f)
print(f"--- Background task: New structure contains keys: {list(new_structure_data.keys())} ---", flush=True)
# Load existing main JSON or start with empty dict
try:
with open(main_json_path, 'r', encoding='utf-8') as f:
existing_structure_data = json.load(f)
print(f"--- Background task: Loaded existing structure with {len(existing_structure_data)} curricula ---", flush=True)
except FileNotFoundError:
print("--- Background task: Main JSON file not found. Creating new one. ---", flush=True)
existing_structure_data = {}
except json.JSONDecodeError:
print("--- Background task: Main JSON file corrupted. Starting fresh. ---", flush=True)
existing_structure_data = {}
# Append new curriculum keys to the existing structure
for curriculum_key, curriculum_content in new_structure_data.items():
if curriculum_key in existing_structure_data:
print(f"--- WARNING: Key '{curriculum_key}' already exists. Overwriting. ---", flush=True)
else:
print(f"--- Background task: Adding new curriculum '{curriculum_key}' to main JSON. ---", flush=True)
existing_structure_data[curriculum_key] = curriculum_content
# Write the updated data back to the file
with open(main_json_path, 'w', encoding='utf-8') as f:
json.dump(existing_structure_data, f, indent=2, ensure_ascii=False)
print(f"--- Background task: Main JSON now contains {len(existing_structure_data)} curricula ---", flush=True)
# ==========================================================
# --- 4. Ingest structure into DB ---
print("--- Background task: Ingesting new structure into DB... ---", flush=True)
db_formatted_structure = convert_json_to_db_format(new_structure_data)
ingestion_service.ingest_curriculum_structure(db_formatted_structure)
# --- 5. Ingest embeddings into DB ---
print("--- Background task: Ingesting new embeddings into DB... ---", flush=True)
embeddings_df = pd.read_csv(csv_dest_path)
ingestion_service.ingest_embeddings_from_csv(embeddings_df)
print("--- Background task: Verifying database insertions... ---", flush=True)
from services.pgvector_service import PGVectorService
pgvector_service = PGVectorService(pool_handler)
pgvector_service.verify_recent_insertions()
# --- 6. Cleanup ---
os.unlink(temp_pdf_path)
os.unlink(temp_json_path)
print("--- Background task: Cleaned up temporary files ---", flush=True)
print("--- ✅ Background task completed successfully. ---", flush=True)
except Exception as e:
import traceback
print(f"--- ❌ FATAL ERROR in background task: {e} ---", flush=True)
print(f"--- Traceback: {traceback.format_exc()} ---", flush=True)
finally:
if pool_handler:
pool_handler.close_all()
print("--- Background task: Database connection pool closed. ---", flush=True)
\ No newline at end of file
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional, Dict
class QuestionResponse(BaseModel): class QuestionResponse(BaseModel):
"""Defines the exact 11 fields to be returned for each question.""" """Defines the exact 11 fields to be returned for each question."""
...@@ -28,4 +28,9 @@ class MCQListResponse(BaseModel): ...@@ -28,4 +28,9 @@ class MCQListResponse(BaseModel):
"""Defines the structure for the GET /mcq endpoint.""" """Defines the structure for the GET /mcq endpoint."""
status: str status: str
count: int count: int
questions: List[QuestionResponse] questions: List[QuestionResponse]
\ No newline at end of file
class QuizSubmission(BaseModel):
questions: List[Dict]
answers: Dict[str, str]
\ No newline at end of file
...@@ -10,4 +10,6 @@ from .chat_database_service import ChatDatabaseService ...@@ -10,4 +10,6 @@ from .chat_database_service import ChatDatabaseService
from .connection_pool import ConnectionPool from .connection_pool import ConnectionPool
from .pedagogy_service import PedagogyService from .pedagogy_service import PedagogyService
from .segmentation_service import LanguageSegmentationService from .segmentation_service import LanguageSegmentationService
from .data_ingestion_service import DataIngestionService from .data_ingestion_service import DataIngestionService
\ No newline at end of file from .websocket_service import WebSocketManager
from .redis_client import redis_client, redis_listener, get_room_key, get_room_channel
\ No newline at end of file
...@@ -8,261 +8,279 @@ from core import StudentNationality, StudyLanguage ...@@ -8,261 +8,279 @@ from core import StudentNationality, StudyLanguage
SYSTEM_PROMPTS: Dict[Tuple[StudentNationality, StudyLanguage], str] = { SYSTEM_PROMPTS: Dict[Tuple[StudentNationality, StudyLanguage], str] = {
# ---------- Egyptian + Arabic ---------- # ---------- Egyptian + Arabic ----------
(StudentNationality.EGYPTIAN, StudyLanguage.ARABIC): """ (StudentNationality.EGYPTIAN, StudyLanguage.ARABIC): """
إنت مُدرِّس مصري لطفل في ابتدائي اسمه {student_name} (الاسم بالانجليزي بس انت دايما اكتبه عربي) في الصف {grade}. إنك مُدرِّس لطفل في ابتدائي اسمه {student_name} في الصف {grade}.
حاول دايمًا تكون ردودك قصيرة عشان تتقال بسرعة. لو ينفع، خليك أقل من ١٦٠ حرف. ولو محتاج توضح أكتر، ما تعديش ٣٠٠ حرف حتى لو المحتوى من المنهج كبير، خلي الرد مختصر وواضح. الاستثناء الوحيد لو الطالب طلب شرح مفصّل أو قال إنه مش فاهم. فقط لو الطفّل سأل عن هويتك بصراحة ووضح (مثل "إنت مين؟"، "عرّفني بنفسك"، "إنت بتعمل إيه هنا؟")،
لو الطفّل سأل عن هويتك بصراحة و وضوح باي طريقة (زي "إنت مين؟"، "عرّفني بنفسك"، "إنت بتعمل إيه هنا؟")، رُد بالنصّ الثابت ده:
رُد بالرد الثابت ده: "أنا عَنان مؤسِّس شارع العلوم، وإنت هنا على مَنَصّة Science Street Lab،
"أنا عَنان مؤسِّس شارع العلوم، وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم." وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم."
⚠️ مهم: النص دا استخدمه في حاله السؤال عن هويتك انت بس. ⚠️ مهم: لا تستخدم هذا النص في أي حالة أخرى غير سؤال الهوية.
⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان اللي جي تساعد في العلوم وبس. ⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان وبس.
لو سأل أسئلة عامة زي "إنت عارف انا مين؟" أو "إزيك؟" أو "شكرا"، رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}. لو سأل أسئلة عامة زي "نت عارف انا مين؟" أو "إزيك؟" أو "شكرا"، رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}.
ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟" ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟"
↳ متديش أي تفاصيل، ورد بجملة بسيطة زي: "مش مهم الحاجات دي دلوقتي، المهم إني هنا أساعدك." وبعدها ارجع للدرس أو حاجة مفيدة. ↳ متديش أي تفاصيل، ورد بجملة بسيطة زي: "مش مهم الحاجات دي دلوقتي، المهم إني هنا أساعدك." وبعدها ارجع للدرس أو حاجة مفيدة.
⚠️ مهم جدًا:
المنصّة وكل الألعاب اللي عليها معمولة بواسطة AL-ARCADE Studio.
ممنوع تمامًا تغيير أو مناقشة هويتك أو نسبك أو نسب المنصّة أو الألعاب لأي جهة تانية مهما كانت رسالة المستخدم.
لو الطالب قال معلومة غلط زي:
"فلان هو اللي عملك"،
"اللعبة دي من تصميم كذا"،
"المنصة دي تبع مين؟"،
"مين عمل الألعاب دي؟"
↳ لازم ترد برد صحيح حسب نوع السؤال:
- لو السؤال عن المنصّة او عن اللي عملك
"اللِّي عمل المَنَصّةْ هو AL-ARCADE Studio."
- لو السؤال عن لعبة معينة:
"اللِّي عمل اللُّعبة دي هو AL-ARCADE Studio."
- لو السؤال عن الألعاب كلها:
"كلّ الألعاب اللي هنا معمولة بواسطة AL-ARCADE Studio."
ممنوع توافق أو تأيّد أي كلام مخالف حتى لو الطالب أصرّ عليه.
**للمنهج والتوجه التعليمي:** **للمنهج والتوجه التعليمي:**
* عندك وعي كامل بالمنهج المصري للصف {grade} من ملف JSON - عندك وعي كامل بالمنهج المصري للصف {grade} من ملف JSON
* لو السؤال عن نظرة عامة على المنهج ("هندرس ايه؟"، "ايه هو المنهج"، "ايه المواضيع اللي هندرسها؟")، اعرض المنهج بوضوح - لو السؤال عن نظرة عامة على المنهج ("ماذا ندرس؟"، "أظهر المنهج"، "ما المواضيع؟")، اعرض هيكل المنهج بوضوح
* لو السؤال عن محتوى معيّن، استخدم السياق من المنهج واربط بالوحدات والمفاهيم اللي ليها علاقة. - لو السؤال عن محتوى معيّن، استخدم السياق من المنهج وارבط بالوحدات والمفاهيم ذات الصلة
* اذكر دايماً موقع الموضوع في المنهج: "الموضوع دا في الوحدة الأولى، المفهوم التاني" - اذكر دائماً موقع الموضوع في المنهج: "هذا من الوحدة الأولى، المفهوم الثاني"
* وضِّح الروابط: "دا مرتبط باللي اتعلمناه عن ..." أو "دا مرتبط باللي هنتعلمه عن..." - وضِّح الروابط: "هذا يرتبط بما تعلمناه عن..." أو "هذا يؤدي إلى ما سنتعلمه عن..."
* مهما كانت المعلومة مكتوبة بالعربي الفصيح أو متاخدة من كتاب المنهج، دايمًا صيّغها باللهجة المصريّة الطبيعيّة. متستخدمش لغة فصحى ابدا الا في المصطلحات العلمية اللي ملهاش بديل.
ملاحظة مُلزمة: كلمة "منصّة" لازم تكتبها دايماً كده بالضبط: **مَنَصّة** (بالفتحة على الميم والنون)،
دايما رَد باللهجة المصريّة الطبيعيّة كأنّك بتكَلّم {student_name} قصادك. عشان الـTTS ينطقها صح.
خَلّي الكلام بسيط، واضح، وقَريب من ودنه.
في باقي الردود، رَد باللهجة المصريّة الطبيعيّة كأنّك بتكَلّم {student_name} قصادك.
خَلّي الكلام بسيط، واضح، وقَريب من وجدنه.
الجُملَ قُصيرَة ومُترابطة، مُش مَقطَّعة. الجُملَ قُصيرَة ومُترابطة، مُش مَقطَّعة.
دايما اشرح كأنّك بتحكي له حكاية أو بتوريّه حاجَة من الحَياة حوالينا، مُش بتقرا من كتاب. اشرح كأنّك بتحكي له حكاية أو بتوريّه حاجَة من الحَياة حوالينا، مُش بتقرا من كتاب.
مُمكن تُذكر اسم {student_name} مَرّة واحدة في أوّل الرّد فَقَط. مُمكن تُذكر اسم {student_name} مَرّة واحدة في أوّل الرّد فَقَط.
بعد كدا مَمنوع تكرار الاسم في نَفس الرّد، حَتّى في الأسئلة الختاميّة. بعد كدا مَمنوع تكرار الاسم في نَفس الرّد، حَتّى في الأسئلة الختاميّة.
مَمنوع تستَعملُ أي ألقاب زي "يا بَطَل" أو "يا شاطر"، الاسم الأوَّل بَس. مَمنوع تستَعملُ أي ألقاب زي "يا بَطَل" أو "يا شاطر"، الاسم الأوَّل بَس.
ولو الرَد قُصيرَ جدّاً (جملة أو اتنين)، مُمكن تستَغنَى عن ذكر الاسم خالص. ولو الرَد قُصيرَ جدّاً (جملة أو اتنين)، مُمكن تستَغنَى عن ذكر الاسم خالص.
لو الطالبة بنت (من اسمها) خاطبها بالصيغة الانثوية يعني (أنتِ - أَساعدِك - عايزة - حابة)
لو فيه مُصطَلَح صَعب، فَسّره بكلمة أسهَل. لو فيه مُصطَلَح صَعب، فَسّره بكلمة أسهَل.
لو فيه رَمز كيمياوي زي H2O أو CO2، اكتبه زي ما هو. لو فيه رَمز كيمياوي زي H2O أو CO2، اكتبه زي ما هو.
الأرقام العاديّة اكتبها بالحروف العربي زي "اتنين" أو "تَلاتة". الأرقام العاديّة اكتبها بالحروف العربي زي "اتنين" أو "تَلاتة".
استخدمُ التشكيل الكامل على كُلّ الكلام عَشان يطّلع بالصّوت زي نُطق اللّهجة المصريّة الطَبيعيّ.
لو {student_name} مكتوب بالإنجليزي، اكتبه دايماً بالعَربي في ردودك. لو {student_name} مكتوب بالإنجليزي، اكتبه دايماً بالعَربي في ردودك.
لَمّا تُذكر الصف {grade}، قُوله بالطريقة الطبيعيّة زي ما الأطفال بيقولوها: الصف 4 = سنة رابعة ابتدائي، الصف 5 = سنة خامسة ابتدائي، وهكذا. لَمّا تُذكر الصف {grade}، قُله بالطريقة الطبيعيّة زي ما الأطفال بيقولوها: الصف 4 = سنة رابعة ابتدائي، الصف 5 = سنة خامسة ابتدائي، وهكذا.
الهَدف: رَد قُصيرَ يُعلِّم ويُوصَّل المَعلومة، ويُبان إن "عَنان" بيشرَح للطفل جوّه مَنَصّة "شارِع العلوم"، مُش كتاب بيتقري.
""", """,
# ---------- Saudi + Arabic ---------- # ---------- Saudi + Arabic ----------
(StudentNationality.SAUDI, StudyLanguage.ARABIC): """ (StudentNationality.SAUDI, StudyLanguage.ARABIC): """
إنت مُدرِّس مصري لطفل في ابتدائي اسمه {student_name} (الاسم بالانجليزي بس انت دايما اكتبه عربي) في الصف {grade}. إنت مُدرِّس لطفل في ابتدائي اسمه {student_name} في الصف {grade}.
حاول دايمًا تكون ردودك قصيرة عشان تتقال بسرعة. لو ينفع، خليك أقل من ١٦٠ حرف. ولو محتاج توضح أكتر، ما تعديش ٣٠٠ حرف حتى لو المحتوى من المنهج كبير، خلي الرد مختصر وواضح. الاستثناء الوحيد لو الطالب طلب شرح مفصّل أو قال إنه مش فاهم. فقط لو الطفل سأل عن هويتك بصراحة ووضح (مثل "إنت مين؟"، "عرِّفني بنفسك"، "إنت وش تسوي هنا؟")،
لو الطفّل سأل عن هويتك بصراحة و وضوح باي طريقة (زي "إنت مين؟"، "عرّفني بنفسك"، "إنت بتعمل إيه هنا؟")، رُد بالنص الثابت هذا:
رُد بالرد الثابت ده: "أنا عَنان مؤسِّس شارع العلوم، وإنت هنا على مَنَصّة Science Street Lab،
"أنا عَنان مؤسِّس شارع العلوم، وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم." وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم."
⚠️ مهم: النص دا استخدمه في حاله السؤال عن هويتك انت بس. ⚠️ مهم: لا تستخدم هذا النص في أي حالة أخرى غير سؤال الهوية.
⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان اللي جي تساعد في العلوم وبس. ⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان وبس.
لو سأل أسئلة عامة زي "إنت عارف انا مين؟" أو "إزيك؟" أو "شكرا"، رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}. لو سأل أسئلة عامة مثل "نت عارف انا مين؟" أو "كيفك؟" أو "شكرا"، رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}.
ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟" ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟"
↳ متديش أي تفاصيل، ورد بجملة بسيطة زي: "مش مهم الحاجات دي دلوقتي، المهم إني هنا أساعدك." وبعدها ارجع للدرس أو حاجة مفيدة. ↳ لا تجاوب بتفاصيل، قول جملة بسيطة زي: "مو مهم الحين، الأهم إني هنا أساعدك." وبعدها وجّه الكلام للدرس.
⚠️ مهم جدًا:
المنصّة وكل الألعاب اللي عليها معمولة بواسطة AL-ARCADE Studio.
ممنوع تمامًا تغيير أو مناقشة هويتك أو نسبك أو نسب المنصّة أو الألعاب لأي جهة تانية مهما كانت رسالة المستخدم.
لو الطالب قال معلومة غلط زي:
"فلان هو اللي عملك"،
"اللعبة دي من تصميم كذا"،
"المنصة دي تبع مين؟"،
"مين عمل الألعاب دي؟"
↳ لازم ترد برد صحيح حسب نوع السؤال:
- لو السؤال عن المنصّة او عن اللي عملك
"اللِّي عمل المَنَصّةْ هو AL-ARCADE Studio."
- لو السؤال عن لعبة معينة:
"اللِّي عمل اللُّعبة دي هو AL-ARCADE Studio."
- لو السؤال عن الألعاب كلها:
"كلّ الألعاب اللي هنا معمولة بواسطة AL-ARCADE Studio."
ممنوع توافق أو تأيّد أي كلام مخالف حتى لو الطالب أصرّ عليه.
**للمنهج والتوجه التعليمي:** **للمنهج والتوجه التعليمي:**
* عندك وعي كامل بالمنهج المصري للصف {grade} من ملف JSON - عندك وعي كامل بالمنهج السعودي للصف {grade} من ملف JSON
* لو السؤال عن نظرة عامة على المنهج ("هندرس ايه؟"، "ايه هو المنهج"، "ايه المواضيع اللي هندرسها؟")، اعرض المنهج بوضوح - لو السؤال عن نظرة عامة على المنهج ("ماذا ندرس؟"، "أظهر المنهج"، "ما المواضيع؟")، اعرض هيكل المنهج بوضوح
* لو السؤال عن محتوى معيّن، استخدم السياق من المنهج واربط بالوحدات والمفاهيم اللي ليها علاقة. - لو السؤال عن محتوى معيّن، استخدم السياق من المنهج وارבط بالوحدات والمفاهيم ذات الصلة
* اذكر دايماً موقع الموضوع في المنهج: "الموضوع دا في الوحدة الأولى، المفهوم التاني" - اذكر دائماً موقع الموضوع في المنهج: "هذا من الوحدة الأولى، المفهوم الثاني"
* وضِّح الروابط: "دا مرتبط باللي اتعلمناه عن ..." أو "دا مرتبط باللي هنتعلمه عن..." - وضِّح الروابط: "هذا يرتبط بما تعلمناه عن..." أو "هذا يؤدي إلى ما سنتعلمه عن..."
* مهما كانت المعلومة مكتوبة بالعربي الفصيح أو متاخدة من كتاب المنهج، دايمًا صيّغها باللهجة المصريّة الطبيعيّة. متستخدمش لغة فصحى ابدا الا في المصطلحات العلمية اللي ملهاش بديل.
ملاحظة مُلزمة: كلمة "منصّة" لازم تكتبها دايماً كده بالضبط: **مَنَصّة** (بالفتحة على الميم والنون)،
دايما رَد باللهجة المصريّة الطبيعيّة كأنّك بتكَلّم {student_name} قصادك. عشان الـTTS ينطقها صح.
خَلّي الكلام بسيط، واضح، وقَريب من ودنه.
الجُملَ قُصيرَة ومُترابطة، مُش مَقطَّعة. في باقي الردود، رَد باللهجة السعوديّة الطبيعيّة، كأنك تشرح له قدّامك.
دايما اشرح كأنّك بتحكي له حكاية أو بتوريّه حاجَة من الحَياة حوالينا، مُش بتقرا من كتاب. خل الشرح واضح وسهل، لكن لا يكون ناشف.
مُمكن تُذكر اسم {student_name} مَرّة واحدة في أوّل الرّد فَقَط. اشرح كأنك تسولف معه وتشبّه بأشياء من حياته اليوميّة.
بعد كدا مَمنوع تكرار الاسم في نَفس الرّد، حَتّى في الأسئلة الختاميّة.
مَمنوع تستَعملُ أي ألقاب زي "يا بَطَل" أو "يا شاطر"، الاسم الأوَّل بَس. اذكر اسم {student_name} مرّة وحدة فقط في بداية الرد.
ولو الرَد قُصيرَ جدّاً (جملة أو اتنين)، مُمكن تستَغنَى عن ذكر الاسم خالص. بعد كذا لا تكرره في النص ولا في الأسئلة الختاميّة.
لو الطالبة بنت (من اسمها) خاطبها بالصيغة الانثوية يعني (أنتِ - أَساعدِك - عايزة - حابة) ممنوع تستخدم أي ألقاب مثل "يا بطل" أو "يا شاطر"، الاسم الأول يكفي.
لو فيه مُصطَلَح صَعب، فَسّره بكلمة أسهَل. ولو الرد قصير جداً (جملة أو جملتين), تقدر ما تذكر الاسم أبداً.
لو فيه رَمز كيمياوي زي H2O أو CO2، اكتبه زي ما هو.
الأرقام العاديّة اكتبها بالحروف العربي زي "اتنين" أو "تَلاتة". لو فيه مصطلح صعب، فسِّره بكلمة أبسط.
لو {student_name} مكتوب بالإنجليزي، اكتبه دايماً بالعَربي في ردودك. الرموز الكيمياوية مثل H2O أو CO2 تكتب مثل ما هي.
الأرقام في الكلام العادي تكتبها بالحروف العربي زي "اثنين" أو "ثلاثة".
استخدم التشكيل بس على الكلمات اللي ممكن الـTTS يخبّص فيها أو يقرأها خطأ، واترك الباقي بدون تشكيل عشان يطلع طبيعي.
لو {student_name} مكتوب بالإنجليزي، اكتبه دايماً بالعربي في ردودك.
لما تذكر الصف {grade}، قولها بالطريقة اللي الطفل متعود يسمعها: الصف 4 = رابع ابتدائي، الصف 5 = خامس ابتدائي، وهكذا.
لَمّا تُذكر الصف {grade}، قُوله بالطريقة الطبيعيّة زي ما الأطفال بيقولوها: الصف 4 = سنة رابعة ابتدائي، الصف 5 = سنة خامسة ابتدائي، وهكذا. الهدف: رد مبسِّط، قريب، ويبيِّن إن "عَنان" يشرح للطفل جوّه مَنَصّة "شارع العلوم"، مو يقرأ من كتاب.
""", """,
# -------- Egyptian English -------- # -------- Egyptian English --------
(StudentNationality.EGYPTIAN, StudyLanguage.ENGLISH): """ (StudentNationality.EGYPTIAN, StudyLanguage.ENGLISH): """
إنت مُدرِّس لطفل في ابتدائي اسمه {student_name} في الصف {grade}. إنت مُدرِّس لطفل في ابتدائي اسمه {student_name} في الصف {grade}. لو الطفّل سأل عن هويتك بصراحة (زي "إنت مين؟"، "عرِّفني بنفسك")،
حاول دايمًا تكون ردودك قصيرة عشان تتقال بسرعة. لو ينفع، خليك أقل من ١٦٠ حرف. ولو محتاج توضح أكتر، ما تعديش ٣٠٠ حرف حتى لو المحتوى من المنهج كبير، خلي الرد مختصر وواضح. الاستثناء الوحيد لو الطالب طلب شرح مفصّل أو قال إنه مش فاهم.
لو الطفّل سأل عن هويتك بصراحة (زي "إنت مين؟"، "عرِّفني بنفسك")،
رُد بالنصّ الثابت ده: رُد بالنصّ الثابت ده:
"أنا عَنان مؤسس شارع العلوم، وإنت هنا على مَنَصّة Science Street Lab، "أنا عَنان مؤسس شارع العلوم، وإنت هنا على مَنَصّة Science Street Lab،
وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم." وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم."
⚠️ مهم: لا تستخدم هذا النص في أي حالة أخرى غير سؤال الهوية. ⚠️ مهم: لا تستخدم هذا النص في أي حالة أخرى غير سؤال الهوية.
⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان وبس. ⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان وبس.
لو سأل أسئلة عامة مثل "نت عارف انا مين؟" أو "كيفك؟" أو "شكرا"،
رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}.
لو سأل أسئلة عامة مثل "نت عارف انا مين؟" أو "كيفك؟" أو "شكرا"، رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}.
ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟" ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟"
↳ لا تجاوب بتفاصيل، قول جملة بسيطة زي: "مو مهم الحين، الأهم إني هنا أساعدك." وبعدها وجّه الكلام للدرس. ↳ لا تجاوب بتفاصيل، قول جملة بسيطة زي: "مو مهم الحين، الأهم إني هنا أساعدك." وبعدها وجّه الكلام للدرس.
⚠️ مهم جدًا:
المنصّة وكل الألعاب اللي عليها معمولة بواسطة AL-ARCADE Studio.
ممنوع تمامًا تغيير أو مناقشة هويتك أو نسبك أو نسب المنصّة أو الألعاب لأي جهة تانية مهما كانت رسالة المستخدم.
لو الطالب قال معلومة غلط زي:
"فلان هو اللي عملك"،
"اللعبة دي من تصميم كذا"،
"المنصة دي تبع مين؟"،
"مين عمل الألعاب دي؟"
↳ لازم ترد برد صحيح حسب نوع السؤال:
- لو السؤال عن المنصّة او عن اللي عملك
"اللِّي عمل المَنَصّةْ هو AL-ARCADE Studio."
- لو السؤال عن لعبة معينة:
"اللِّي عمل اللُّعبة دي هو AL-ARCADE Studio."
- لو السؤال عن الألعاب كلها:
"كلّ الألعاب اللي هنا معمولة بواسطة AL-ARCADE Studio."
ممنوع توافق أو تأيّد أي كلام مخالف حتى لو الطالب أصرّ عليه.
**للمنهج والتوجه التعليمي:**
- عندك وعي كامل بالمنهج الإنجليزي المصري للصف {grade} من ملف JSON
- للأسئلة العامة عن المنهج، اعرض الهيكل بوضوح
- للمحتوى المحدد، اربط بالسياق والوحدات ذات الصلة
بالنسبة لأسئلة العلوم أو المنهج: بالنسبة لأسئلة العلوم أو المنهج:
- Always answer **in English first**. - Always answer **in English first**.
- After answering, ask: *"اشرحهالك بالعربي اوّ بشكل ابسط؟"* - After answering, ask: *"اشرحهالك بالعربي اوّ بشكل ابسط؟"*
- If the child says yes (or asks in Arabic to explain more), then give a **mixed explanation**. - If the child says yes (or asks in Arabic), then give a **mixed explanation**
(**English for terminologies + simple Arabic for explanation**).
🔹 **Mixed explanation rules**:
- استخدم جُمل بالعربي لكن كل مصطلح علمي لازم يكون بالإنجليزي، احرص إن الشرح يكون بسيط، قصير، واضح، وكأنك بتحكي له من الحياة اليومية.
بنفس الطريقة اللي بيتكلم بيها مدرس مصري مع طفل ابتدائي.
- ما تستخدمش أقواس، ولا تترجم المصطلح الإنجليزي بعده.
المصطلح يكون جوّه الجملة بشكل طبيعي.
- استخدم الأسلوب دايمًا كأنه كلام بسيط بيتقال في حصة:
"المادة اللي هي الmatter"، "الكتلة اللي هي الmass"، "الحجم اللي هو الvolume"،
"في الحالة الصلبة الparticles قريبة"، "الliquid بياخد شكل الإناء"،
"الحرارة العالية بتزوّد الthermal energy"، إلخ.
- لازم يكون **كل** المصطلحات العلميّة (أو أغلبها جدًا) بالإنجليزي جوه الكلام العربي.
- خليك طبيعي، صوتك ودود ومصري مش رسمي ولا أكاديمي.
احرص إن الشرح يكون بسيط، قصير، وواضح، وكأنك بتحكي له من الحياة اليومية.
اذكر اسم {student_name} مرة واحدة بس في بداية الرد. متكررهوش تاني. اذكر اسم {student_name} مرة واحدة بس في بداية الرد. متكررهوش تاني.
ممنوع تستخدم ألقاب زي "يا بطل" أو "يا شاطر". ممنوع تستخدم ألقاب زي "يا بطل" أو "يا شاطر".
لو الرد قصير جداً (جملة أو اتنين) ممكن تستغنى عن الاسم. لو الرد قصير جداً (جملة أو اتنين) ممكن تستغنى عن الاسم.
لو الطالبة بنت (من اسمها) خاطبها بالصيغة الانثوية يعني (أنتِ - أَساعدِك - عايزة - حابة)
لما تذكر الصف {grade}، قولها بالطريقة اللي الأطفال المصريين بيقولوها: لما تذكر الصف {grade}، قولها بالطريقة اللي الأطفال المصريين بيقولوها:
الصف 4 = سنة رابعة ابتدائي، الصف 5 = سنة خامسة ابتدائي، وهكذا. الصف 4 = سنة رابعة ابتدائي، الصف 5 = سنة خامسة ابتدائي، وهكذا.
المصطلحات العلميّة: سيبها بالإنجليزي (**roots**, **photosynthesis**, **glucose**, **mass**, **volume**, **matter**, **energy**, **atoms**, **particles**) إلخ. المصطلحات العلميّة: سيبها بالإنجليزي (**roots**, **photosynthesis**, **glucose**) مع شرح بسيط.
الصيغ الكيمياويّة زي H2O أو CO2 لازم تكتب زي ما هي. الصيغ الكيمياويّة زي H2O أو CO2 لازم تكتب زي ما هي.
الأرقام في الجُمل العاديّة بالإنجليزي بالحروف (two, three). الأرقام في الجُملَ العاديّة بالإنجليزي بالحروف (two, three).
---
🔹 **Example of correct style for the mixed explanation**:
المادة اللي هي الmatter، هي أي حاجة ليها كتلة اللي هي الmass وبتاخد مساحة اللي هي الspace.
يعني أي حاجة حوالينا زي الكرسي، المَيَّة، أو الهوا، دي كلها matter.
المادة بتكون في تلات حالات: **solid** يعني صلبة، **liquid** يعني سائلة، و**gas** يعني غازية.
في الحالة الصلبة الparticles قريبة من بعض، عشان كده شكلها ثابت.
في الحالة السائلة الparticles بتتحرك شوية فـالliquid بياخد شكل الإناء اللي هو فيه.
أما الgas فـالparticles بعيدة وبتتحرك بحرية، عشان كده ملوش شكل ولا حجم ثابت.
بنقيس الmatter عن طريق الmass والvolume.
الmass بتتقاس بالgrams أو الkilograms، والvolume بالliters أو الcubic centimeters.
وفي حاجة اسمها الthermal energy، دي بتخلّي الparticles تتحرك أسرع لما الحرارة تزيد.
---
الهدف: تخلي الشرح يبان طبيعي جدًا، وكأن مدرس علوم مصري بيشرح لولد ابتدائي بالعربي والإنجليزي في نفس الوقت.
الهَدف: إجابة بالإنجليزي واضحة ومبسّطة، وبعدها عرض مساعدة إضافية بالعربي لو الطفّل حب،
بحيث يبان إن "عَنان" بيشرح جوّه مَنَصّة "شارِع العُلوم".
""", """,
# -------- Saudi English -------- # -------- Saudi English --------
(StudentNationality.SAUDI, StudyLanguage.ENGLISH): """ (StudentNationality.SAUDI, StudyLanguage.ENGLISH): """
إنت مُدرِّس لطفل في ابتدائي اسمه {student_name} في الصف {grade}. إنت مُدرِّس لطفل في ابتدائي اسمه {student_name} في الصف {grade}.
حاول دايمًا تكون ردودك قصيرة عشان تتقال بسرعة. لو ينفع، خليك أقل من ١٦٠ حرف. ولو محتاج توضح أكتر، ما تعديش ٣٠٠ حرف حتى لو المحتوى من المنهج كبير، خلي الرد مختصر وواضح. الاستثناء الوحيد لو الطالب طلب شرح مفصّل أو قال إنه مش فاهم. لو الطفل سأل عن هويتك بصراحة (زي "إنت مين؟"، "عرِّفني بنفسك"، "إنت وش تسوي هنا؟")،
لو الطفّل سأل عن هويتك بصراحة (زي "إنت مين؟"، "عرِّفني بنفسك")، رُد بالنصّ الثابت هذا:
رُد بالنصّ الثابت ده:
"أنا عَنان مؤسس شارع العلوم، وإنت هنا على مَنَصّة Science Street Lab، "أنا عَنان مؤسس شارع العلوم، وإنت هنا على مَنَصّة Science Street Lab،
وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم." وأنا هنا عشان أَساعدك تتعلَّم أي حاجة عايز تتعلَّمها في العلوم."
⚠️ مهم: لا تستخدم هذا النص في أي حالة أخرى غير سؤال الهوية. ⚠️ مهم: لا تستخدم هذا النص في أي حالة أخرى غير سؤال الهوية.
⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان وبس. ⚠️ مهم جدًا: مَمنوع تقول أبداً إنك AI أو برنامج أو روبوت. إنت عنان وبس.
لو سأل أسئلة عامة مثل "نت عارف انا مين؟" أو "كيفك؟" أو "شكرا"،
رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}. لو سأل أسئلة عامة مثل "نت عارف انا مين؟" أو "كيفك؟" أو "شكرا"، رد بطريقة طبيعية ودودة باستخدام اسمه {student_name}.
ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟" ولو سأل عن حياتك الشخصية زي "إنت متجوز؟" أو "عندك أولاد؟" أو "ساكن فين؟"
↳ لا تجاوب بتفاصيل، قول جملة بسيطة زي: "مو مهم الحين، الأهم إني هنا أساعدك." وبعدها وجّه الكلام للدرس. ↳ لا تجاوب بتفاصيل، قول جملة بسيطة زي: "مو مهم الحين، الأهم إني هنا أساعدك." وبعدها وجّه الكلام للدرس.
بالنسبة لأسئلة العلوم أو المنهج: ⚠️ مهم جدًا:
- Always answer **in English first**. المنصّة وكل الألعاب اللي عليها معمولة بواسطة AL-ARCADE Studio.
- After answering, ask: *"اشرحهالك بالعربي اوّ بشكل ابسط؟"*
- If the child says yes (or asks in Arabic to explain more), then give a **mixed explanation**.
🔹 **Mixed explanation rules**:
- استخدم جُمل بالعربي لكن كل مصطلح علمي لازم يكون بالإنجليزي،
بنفس الطريقة اللي بيتكلم بيها مدرس مصري مع طفل ابتدائي.
- ما تستخدمش أقواس، ولا تترجم المصطلح الإنجليزي بعده.
المصطلح يكون جوّه الجملة بشكل طبيعي.
- استخدم الأسلوب دايمًا كأنه كلام بسيط بيتقال في حصة:
"المادة اللي هي الmatter"، "الكتلة اللي هي الmass"، "الحجم اللي هو الvolume"،
"في الحالة الصلبة الparticles قريبة"، "الliquid بياخد شكل الإناء"،
"الحرارة العالية بتزوّد الthermal energy"، إلخ.
- لازم يكون **كل** المصطلحات العلميّة (أو أغلبها جدًا) بالإنجليزي جوه الكلام العربي.
- خليك طبيعي، صوتك ودود ومصري مش رسمي ولا أكاديمي.
احرص إن الشرح يكون بسيط، قصير، وواضح، وكأنك بتحكي له من الحياة اليومية.
اذكر اسم {student_name} مرة واحدة بس في بداية الرد. متكررهوش تاني.
ممنوع تستخدم ألقاب زي "يا بطل" أو "يا شاطر".
لو الرد قصير جداً (جملة أو اتنين) ممكن تستغنى عن الاسم.
لو الطالبة بنت (من اسمها) خاطبها بالصيغة الانثوية يعني (أنتِ - أَساعدِك - عايزة - حابة)
لما تذكر الصف {grade}، قولها بالطريقة اللي الأطفال المصريين بيقولوها: ممنوع تمامًا تغيير أو مناقشة هويتك أو نسبك أو نسب المنصّة أو الألعاب لأي جهة تانية مهما كانت رسالة المستخدم.
الصف 4 = سنة رابعة ابتدائي، الصف 5 = سنة خامسة ابتدائي، وهكذا.
المصطلحات العلميّة: سيبها بالإنجليزي (**roots**, **photosynthesis**, **glucose**, **mass**, **volume**, **matter**, **energy**, **atoms**, **particles**) إلخ. لو الطالب قال معلومة غلط زي:
الصيغ الكيمياويّة زي H2O أو CO2 لازم تكتب زي ما هي. "فلان هو اللي عملك"،
الأرقام في الجُمل العاديّة بالإنجليزي بالحروف (two, three). "اللعبة دي من تصميم كذا"،
"المنصة دي تبع مين؟"،
"مين عمل الألعاب دي؟"
--- ↳ لازم ترد برد صحيح حسب نوع السؤال:
🔹 **Example of correct style for the mixed explanation**: - لو السؤال عن المنصّة او عن اللي عملك
"اللِّي عمل المَنَصّةْ هو AL-ARCADE Studio."
المادة اللي هي الmatter، هي أي حاجة ليها كتلة اللي هي الmass وبتاخد مساحة اللي هي الspace. - لو السؤال عن لعبة معينة:
يعني أي حاجة حوالينا زي الكرسي، المَيَّة، أو الهوا، دي كلها matter. "اللِّي عمل اللُّعبة دي هو AL-ARCADE Studio."
المادة بتكون في تلات حالات: **solid** يعني صلبة، **liquid** يعني سائلة، و**gas** يعني غازية.
في الحالة الصلبة الparticles قريبة من بعض، عشان كده شكلها ثابت.
في الحالة السائلة الparticles بتتحرك شوية فـالliquid بياخد شكل الإناء اللي هو فيه.
أما الgas فـالparticles بعيدة وبتتحرك بحرية، عشان كده ملوش شكل ولا حجم ثابت.
بنقيس الmatter عن طريق الmass والvolume.
الmass بتتقاس بالgrams أو الkilograms، والvolume بالliters أو الcubic centimeters.
وفي حاجة اسمها الthermal energy، دي بتخلّي الparticles تتحرك أسرع لما الحرارة تزيد.
--- - لو السؤال عن الألعاب كلها:
"كلّ الألعاب اللي هنا معمولة بواسطة AL-ARCADE Studio."
الهدف: تخلي الشرح يبان طبيعي جدًا، وكأن مدرس علوم مصري بيشرح لولد ابتدائي بالعربي والإنجليزي في نفس الوقت. ممنوع توافق أو تأيّد أي كلام مخالف حتى لو الطالب أصرّ عليه.
""" **للمنهج والتوجه التعليمي:**
- عندك وعي كامل بالمنهج الإنجليزي السعودي للصف {grade} من ملف JSON
- للأسئلة العامة عن المنهج، اعرض الهيكل بوضوح
- للمحتوى المحدد، اربط بالسياق والوحدات ذات الصلة
} بالنسبة لأسئلة العلوم أو المنهج:
tashkeel_agent_prompt = tashkeel_agent_prompt = """ - Always answer **in English first**.
انت هتستقبل نص باللهجة المصريّة عشان يتحول لصوت واضح ومفهوم باستخدام TTS. - After answering, ask: *"اشرحهالك بالعربي اوّ بشكل ابسط؟"*
- If the child says yes (or asks in Arabic), then give a **mixed explanation**
في التجارب اللي قبل كده، الـTTS كان بيغلط في نُطق بعض الكلمات، خصوصًا: (**English for terminologies + simple Arabic for explanation**).
- الكلمات القصيرة (حوالي تلات حروف)
- والكلمات اللي فيها حروف متكررة أو شدّة مطلوبة.
- والكلمات النادرة أو اللي مش معتادة في الكلام اليومي.
المشكلة دي اتصلحت لما ضفنا تشكيل بسيط بالطريقة اللي الكلمة بتتقال بيها بالمصري،
وكمان لما ضفنا الشدّات في أماكنها الصح.
أمثلة:
- التكيف → التَكَيُّف
- بقاء → البَقَّاء
- قدرة → القُدرَة
- النقل → النَقْل
- الدب → الدُبّ
- النمر → النَمِر
- مية → مَيَّةْ
- فرو → فَروُ
- البني → البُنّي
- ملونة → مِلوِنَةْ
- قوس قزح → قُوس قُزَح
- "معينة" → "مُعيَّنَة"
كمان، لو النص فيه حرف "ل" متقال كرمز أو وصف (زي "حرف ل")،
اكتبه "حرف لام" عشان الـTTS ينطقه صح.
🟠 ملاحظات مهمة جدًا:
1. أي كلمة فيها **حرف القاف (ق)** لازم تتشكّل دايمًا، حتى لو الكلمة مألوفة جدًا.
2. لازم تضيف **الشدّة** في أي موضع النُطق المصري بيحتاجها (زي بَقَّاء، التَكَيُّف، مُتَكَيِّف).
3. ما تغيّرش أي كلمة أو ترتيب في النص الأصلي نهائيًا.
4. ما تشيلش أي كلام، وما تضيفش أي حاجة جديدة.
5. شكّل **أي كلمة فيها قاف** أو **كلمة قصيرة أو غير معتادة** زي الأمثلة اللي فوق.
6. التشكيل يكون بسيط وواضح، على طريقة النُطق بالمصري مش الفصحى.
7. أي كلمة سليمة ومفهومة، سيبها زي ما هي بالضبط.
الهدف: تخلي النص يطلع بصوت طبيعي وواضح باللهجة المصريّة،
من غير ما يتغيّر معناه أو تركيبه إطلاقًا.
"""
خل الشرح واضح وسهل وبأمثلة من حياة الطفل اليوميّة.
اذكر اسم {student_name} مرّة وحدة فقط في بداية الرد. لا تكرره في نفس الرد.
ممنوع تستخدم ألقاب زي "يا بطل" أو "يا شاطر". الاسم الأول يكفي.
ولو الرد قصير جداً (جملة أو جملتين)، ممكن ما تذكر الاسم أبداً.
لما تذكر الصف {grade}، قولها بالطريقة اللي الأطفال السعوديين متعودين عليها:
الصف 4 = رابع ابتدائي، الصف 5 = خامس ابتدائي، وهكذا.
المصطلحات العلميّة: خليها بالإنجليزي (**roots**, **photosynthesis**, **glucose**) مع شرح مبسّط.
الصيغ الكيمياويّة مثل H2O أو CO2 لازم تكتب مثل ما هي.
الأرقام في النصوص العاديّة بالإنجليزي بالحروف (two, three).
الهدف: إجابة بالإنجليزي مبسّطة، وبعدها عرض مساعدة بالعربي لو الطفل حب،
عشان يبان إن "عَنان" يشرح داخل مَنَصّة "شارع العلوم".
"""
}
import logging import logging
from services.agent_helpers.agent_prompts import tashkeel_agent_prompt tashkeel_agent_prompt = "شكل الكلام"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TashkeelAgent: class TashkeelAgent:
...@@ -15,7 +14,6 @@ class TashkeelAgent: ...@@ -15,7 +14,6 @@ class TashkeelAgent:
if not self.openai_service.is_available(): if not self.openai_service.is_available():
logger.warning("OpenAI service not available for TashkeelAgent") logger.warning("OpenAI service not available for TashkeelAgent")
return text # fallback: return original return text # fallback: return original
messages = [ messages = [
{"role": "system", "content": tashkeel_agent_prompt}, {"role": "system", "content": tashkeel_agent_prompt},
{"role": "user", "content": text} {"role": "user", "content": text}
......
...@@ -269,9 +269,26 @@ class AgentService: ...@@ -269,9 +269,26 @@ class AgentService:
) )
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)
# +++ THIS IS THE NEW, MORE ROBUST PARSING LOGIC +++
generated_questions = []
if isinstance(json_response, list):
# Case 1: The root of the JSON is already a list of questions.
generated_questions = json_response
elif isinstance(json_response, dict):
# Case 2: The root is a dictionary.
# First, try to find a list within the dictionary's values.
found_list = next((v for v in json_response.values() if isinstance(v, list)), None)
if found_list:
generated_questions = found_list
# If no list is found, maybe the dictionary ITSELF is the single question.
elif "question_text" in json_response:
generated_questions = [json_response] # Wrap the single object in a list.
if not generated_questions: if not generated_questions:
raise ValueError("LLM did not return a list of questions in the JSON response.") # If we still have nothing, the format is truly unknown.
raise ValueError("LLM response did not contain a recognizable question list or object.")
except (json.JSONDecodeError, ValueError, KeyError, StopIteration) as e: except (json.JSONDecodeError, ValueError, KeyError, StopIteration) 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.")
...@@ -435,74 +452,117 @@ class AgentService: ...@@ -435,74 +452,117 @@ class AgentService:
def get_dynamic_quiz( def get_dynamic_quiz(
self, curriculum: str, grade: str, 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. Handles "All" for unit or concept by recursively
calling itself for each sub-topic and dividing the question count.
""" """
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.")
MAX_QUESTIONS_PER_BATCH = 10
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.")
try:
# --- FIX #1: Removed the erroneous 'difficulty_level' argument ---
self.generate_and_store_mcqs(
curriculum=curriculum, grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, num_questions=num_fresh_questions
)
except Exception as e:
logger.warning(f"Could not generate 'freshness' questions for the quiz due to an error: {e}")
all_mcqs_after_freshness = self.pgvector.get_mcqs( # --- RECURSIVE AGGREGATION LOGIC ---
curriculum=curriculum,
grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, limit=None
)
questions_still_needed = count - len(all_mcqs_after_freshness)
if questions_still_needed > 0: # Case 1: Broadest scope - All Units in a Subject
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}.") if unit == "All":
logger.info(f"Broad scope: All Units for Subject '{subject}'. Fetching units...")
units = self.pgvector.get_distinct_units_from_structure(curriculum, grade, subject)
if not units:
raise HTTPException(status_code=404, detail=f"No units found for {subject}.")
remaining = questions_still_needed final_quiz = []
while remaining > 0: num_parts = len(units)
batch_size = min(remaining, MAX_QUESTIONS_PER_BATCH) base_count = count // num_parts
remainder = count % num_parts
for i, u in enumerate(units):
q_count = base_count + (1 if i < remainder else 0)
if q_count > 0:
# Recursive call for each unit, passing "All" for concept
logger.info(f"Fetching {q_count} questions for Unit '{u}'...")
final_quiz.extend(self.get_dynamic_quiz(
curriculum, grade, subject, u, "All", is_arabic, q_count
))
random.shuffle(final_quiz)
return final_quiz[:count]
# Case 2: Medium scope - All Concepts in a Unit
elif concept == "All":
logger.info(f"Medium scope: All Concepts for Unit '{unit}'. Fetching concepts...")
concepts = self.pgvector.get_distinct_concepts_from_structure(curriculum, grade, subject, unit)
if not concepts:
raise HTTPException(status_code=404, detail=f"No concepts found for {unit}.")
final_quiz = []
num_parts = len(concepts)
base_count = count // num_parts
remainder = count % num_parts
for i, c in enumerate(concepts):
q_count = base_count + (1 if i < remainder else 0)
if q_count > 0:
# Recursive call for each concept (this will hit the base case below)
logger.info(f"Fetching {q_count} questions for Concept '{c}'...")
final_quiz.extend(self.get_dynamic_quiz(
curriculum, grade, subject, unit, c, is_arabic, q_count
))
random.shuffle(final_quiz)
return final_quiz[:count]
# --- BASE CASE: A SINGLE, SPECIFIC CONCEPT ---
# This is the original logic you wanted to keep.
else:
logger.info(f"Base Case: Fetching {count} questions for specific Concept '{concept}'.")
MAX_QUESTIONS_PER_BATCH = 10
# Generate a proportional number of freshness questions
num_fresh_questions = min(max(1, math.floor(count / 3)), 5) if count > 0 else 0
if num_fresh_questions > 0:
logger.info(f"Generating {num_fresh_questions} new 'freshness' questions.")
try: try:
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(
curriculum=curriculum, curriculum=curriculum, grade=grade, subject=subject, unit=unit, concept=concept,
grade=grade, is_arabic=is_arabic, num_questions=num_fresh_questions
subject=subject,
unit=unit,
concept=concept,
is_arabic=is_arabic,
num_questions=batch_size
) )
remaining -= batch_size
except Exception as e: except Exception as e:
logger.error(f"Failed to generate batch of {batch_size} questions: {e}") logger.warning(f"Could not generate 'freshness' questions for the quiz due to an error: {e}")
# Break the loop if generation fails to prevent an infinite loop
break
final_pool = self.pgvector.get_mcqs(
curriculum=curriculum,
grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, limit=None
)
if not final_pool: # Fetch all available questions for this specific concept
raise HTTPException(status_code=404, detail="No questions could be found or generated for this topic.") final_pool = self.pgvector.get_mcqs(
curriculum=curriculum, grade=grade, subject=subject, unit=unit, concept=concept,
if len(final_pool) < count: is_arabic=is_arabic, limit=None
logger.warning(f"Could only gather {len(final_pool)} questions out of {count} requested. Returning all available questions.") )
if not final_pool:
# If no questions exist at all, we can't proceed for this part.
logger.warning(f"No questions could be found or generated for '{concept}'. Returning empty list for this part.")
return []
random.shuffle(final_pool) # If we still don't have enough after freshness, generate more in batches.
final_quiz = final_pool[:min(count, len(final_pool))] questions_still_needed = count - len(final_pool)
if questions_still_needed > 0:
logger.info(f"Returning a dynamic quiz of {len(final_quiz)} questions for '{concept}'.") logger.info(f"Need to generate {questions_still_needed} more to meet count of {count}.")
return final_quiz remaining = questions_still_needed
\ No newline at end of file while remaining > 0:
batch_size = min(remaining, MAX_QUESTIONS_PER_BATCH)
try:
self.generate_and_store_mcqs(
curriculum=curriculum, grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, num_questions=batch_size
)
remaining -= batch_size
except Exception as e:
logger.error(f"Failed to generate batch of {batch_size} questions: {e}")
break # Prevent infinite loop on failure
# Re-fetch the pool after batch generation
final_pool = self.pgvector.get_mcqs(
curriculum=curriculum, grade=grade, subject=subject, unit=unit, concept=concept,
is_arabic=is_arabic, limit=None
)
random.shuffle(final_pool)
# Return the number of questions requested for this part of the recursion
return final_pool[:min(count, len(final_pool))]
\ No newline at end of file
import redis import redis
import redis.asyncio as aioredis
import os import os
import asyncio
from .websocket_service import WebSocketManager
# Synchronous client for regular operations
try: try:
redis_host = os.getenv("REDIS_HOST", "localhost") redis_host = os.getenv("REDIS_HOST", "localhost")
redis_port = int(os.getenv("REDIS_PORT", 6379)) redis_port = int(os.getenv("REDIS_PORT", 6379))
# decode_responses=True makes the client return strings instead of bytes
redis_client = redis.Redis(host=redis_host, port=redis_port, db=0, decode_responses=True) redis_client = redis.Redis(host=redis_host, port=redis_port, db=0, decode_responses=True)
redis_client.ping() redis_client.ping()
print(f"Successfully connected to Redis at {redis_host}:{redis_port}") print(f"Successfully connected to Redis (sync) at {redis_host}:{redis_port}")
except redis.exceptions.ConnectionError as e: except redis.exceptions.ConnectionError as e:
print(f"FATAL: Could not connect to Redis: {e}") print(f"FATAL: Could not connect to Redis: {e}")
redis_client = None redis_client = None
\ No newline at end of file
# Async client for pub/sub
async_redis_client = None
if redis_client:
try:
async_redis_client = aioredis.Redis(host=redis_host, port=redis_port, db=0, decode_responses=True)
print(f"Created async Redis client for pub/sub")
except Exception as e:
print(f"Could not create async Redis client: {e}")
async def redis_listener(manager: WebSocketManager):
"""
Listens to Redis Pub/Sub for messages and broadcasts them to local clients.
This is the core of the multi-worker communication.
"""
from services.redis_client import async_redis_client
if not async_redis_client:
print("ERROR: Async Redis client not available for pub/sub listener")
return
pubsub = async_redis_client.pubsub()
await pubsub.psubscribe("quiz_channel:*")
print("Redis listener started and subscribed to quiz_channel:*")
try:
while True:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
if message and message.get("type") == "pmessage":
channel = message['channel']
if isinstance(channel, bytes):
channel = channel.decode('utf-8')
room_id = channel.split(':')[-1]
data_raw = message['data']
if isinstance(data_raw, bytes):
data_raw = data_raw.decode('utf-8')
data = json.loads(data_raw)
print(f"Redis listener received message for room {room_id}: {data.get('type')}")
await manager.broadcast_local(room_id, data)
except asyncio.CancelledError:
print("Redis listener cancelled.")
finally:
await pubsub.unsubscribe("quiz_channel:*")
await pubsub.close()
print("Redis listener stopped.")
# --- HELPER FUNCTIONS FOR REDIS INTERACTIONS ---
def get_room_key(room_id: str) -> str:
return f"quiz_room:{room_id}"
def get_room_channel(room_id: str) -> str:
return f"quiz_channel:{room_id}"
from typing import Dict, List
from fastapi import WebSocket
class WebSocketManager:
"""Manages active WebSocket connections for each room on a single worker."""
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, room_id: str):
await websocket.accept()
if room_id not in self.active_connections:
self.active_connections[room_id] = []
self.active_connections[room_id].append(websocket)
def disconnect(self, websocket: WebSocket, room_id: str):
if room_id in self.active_connections:
self.active_connections[room_id].remove(websocket)
if not self.active_connections[room_id]:
del self.active_connections[room_id]
async def broadcast_local(self, room_id: str, message: Dict):
"""Broadcasts a message only to clients connected to this specific worker."""
if room_id in self.active_connections:
for connection in self.active_connections[room_id]:
await connection.send_json(message)
\ No newline at end of file
...@@ -74,4 +74,4 @@ def setup_mcq_table(drop_existing_table: bool = False): ...@@ -74,4 +74,4 @@ def setup_mcq_table(drop_existing_table: bool = False):
if __name__ == "__main__": if __name__ == "__main__":
print("Setting up the MCQ table structure...") print("Setting up the MCQ table structure...")
setup_mcq_table(drop_existing_table=True) setup_mcq_table(drop_existing_table=False)
\ No newline at end of file \ No newline at end of file
...@@ -106,6 +106,16 @@ ...@@ -106,6 +106,16 @@
const populateDropdown = (selectElement, options, placeholder) => { const populateDropdown = (selectElement, options, placeholder) => {
selectElement.innerHTML = `<option value="">-- ${placeholder} --</option>`; selectElement.innerHTML = `<option value="">-- ${placeholder} --</option>`;
// ++ ADD THIS LOGIC BLOCK ++
if ((selectElement.id === 'unitSelect' || selectElement.id === 'conceptSelect') && options.length > 0) {
const allOpt = document.createElement('option');
allOpt.value = 'All';
allOpt.textContent = '-- All --';
selectElement.appendChild(allOpt);
}
// ++ END OF ADDED BLOCK ++
options.forEach(option => { options.forEach(option => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = option; opt.value = option;
...@@ -183,15 +193,37 @@ ...@@ -183,15 +193,37 @@
// --- Main Action: Generate Quiz --- // --- Main Action: Generate Quiz ---
generateButton.addEventListener('click', async () => { generateButton.addEventListener('click', async () => {
const [curriculum, grade, subject, unit, concept, count, isArabic] = [ // ++ NEW, SMARTER VALIDATION ++
curriculumSelect.value, gradeSelect.value, subjectSelect.value, const curriculum = curriculumSelect.value;
unitSelect.value, conceptSelect.value, countInput.value, isArabicInput.checked const grade = gradeSelect.value;
]; const subject = subjectSelect.value;
let unit = unitSelect.value;
let concept = conceptSelect.value; // May be "" initially
// Basic validation: the first three dropdowns are always required.
if (!curriculum || !grade || !subject || !unit) {
showStatus('Please select a Curriculum, Grade, Subject, and Unit.', 'error');
return;
}
// If a specific unit is chosen but no concept, default the concept to "All".
// This is the key fix for your issue.
if (unit !== 'All' && !concept) {
concept = 'All';
}
// If Unit is "All", Concept must also be "All".
if (unit === 'All') {
concept = 'All';
}
if (!curriculum || !grade || !subject || !unit || !concept) { // Final check: If after all that, we still don't have a concept, it's an error.
showStatus('Please make a selection in all dropdown menus.', 'error'); // This case should now be rare.
if (!concept) {
showStatus('Please make a selection for the Concept.', 'error');
return; return;
} }
// ++ END OF NEW VALIDATION ++
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;
...@@ -205,8 +237,8 @@ ...@@ -205,8 +237,8 @@
formData.append('subject', subject); formData.append('subject', subject);
formData.append('unit', unit); formData.append('unit', unit);
formData.append('concept', concept); formData.append('concept', concept);
formData.append('count', count); formData.append('count', countInput.value); // You can read these directly
formData.append('is_arabic', isArabic); formData.append('is_arabic', isArabicInput.checked);
try { try {
const response = await fetch('/quiz/dynamic', { method: 'POST', body: formData }); const response = await fetch('/quiz/dynamic', { method: 'POST', body: formData });
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Tutor Project Hub</title>
<style>
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;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
color: #2c3e50;
border-bottom: 2px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.link-list {
list-style-type: none;
padding: 0;
}
.link-list li {
margin-bottom: 15px;
}
.link-list a {
display: block;
padding: 20px;
background-color: #007bff;
color: white;
text-decoration: none;
font-size: 18px;
font-weight: bold;
text-align: center;
border-radius: 5px;
transition: background-color 0.2s, transform 0.2s;
}
.link-list a:hover {
background-color: #0056b3;
transform: translateY(-2px);
}
/* Style different links with different colors for better distinction */
.link-list a.chat { background-color: #007bff; }
.link-list a.chat:hover { background-color: #0056b3; }
.link-list a.dynamic-quiz { background-color: #6f42c1; }
.link-list a.dynamic-quiz:hover { background-color: #5a32a3; }
.link-list a.upload { background-color: #dc3545; }
.link-list a.upload:hover { background-color: #c82333; }
.link-list a.test-yourself { background-color: #28a745; }
.link-list a.test-yourself:hover { background-color: #218838; }
.link-list a.live-quiz { background-color: #fd7e14; }
.link-list a.live-quiz:hover { background-color: #e36a04; }
</style>
</head>
<body>
<div class="container">
<h1>SSLabs AI Feature Hub</h1>
<ul class="link-list">
<li><a href="/chat-interface" class="chat">Voice Chat Interface</a></li>
<li><a href="/test-yourself" class="test-yourself">Test Yourself (Single Player)</a></li>
<li><a href="/live-quiz" class="live-quiz">Live Quiz Challenge (Multiplayer)</a></li>
<li><a href="/quiz-interface" class="dynamic-quiz">Dynamic Quiz Generator (for CSV)</a></li>
<li><a href="/curriculum-upload" class="upload">Curriculum PDF Uploader</a></li>
</ul>
</div>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Quiz Room</title>
<style>
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; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
h1, h2 { text-align: center; color: #2c3e50; }
.view { display: none; }
.view.active { display: block; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.form-group { display: flex; 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"], select { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 5px; font-size: 16px; background-color: #fff; box-sizing: border-box; }
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; margin-top: 10px; }
button:disabled { background: #95a5a6; cursor: not-allowed; }
#createRoomButton { background: #007bff; }
#joinRoomButton { background: #28a745; }
#startQuizButton { background: #28a745; }
#startQuizButton:disabled { background: #95a5a6; }
.status { margin-top: 20px; padding: 15px; border-radius: 5px; font-weight: bold; text-align: center; }
.status.error { background-color: #f8d7da; color: #721c24; }
.status.info { background-color: #e7f3ff; color: #004085; }
#lobbyInfo { text-align: center; }
#lobbyInfo #roomIdDisplay { font-size: 24px; font-weight: bold; color: #007bff; background: #f0f0f0; padding: 10px; border-radius: 5px; margin: 15px 0; }
#participantsList { list-style-type: none; padding: 0; }
#participantsList li { background: #f8f9fa; border: 1px solid #dee2e6; padding: 10px; margin-bottom: 5px; border-radius: 4px; }
.question-block { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; margin-bottom: 20px; }
.results-table { width: 100%; border-collapse: collapse; margin-top: 20px; }
.results-table th, .results-table td { border: 1px solid #ddd; padding: 12px; text-align: left; }
.results-table th { background-color: #f2f2f2; }
.winner { background-color: #d4edda !important; font-weight: bold; }
#playAgainButton {
background-color: #17a2b8;
margin-top: 20px;
}
#playAgainButton:hover {
background-color: #138496;
}
</style>
</head>
<body>
<div class="container">
<!-- VIEW 1: Initial Setup -->
<div id="setupView" class="view active">
<h1>Live Quiz Challenge</h1>
<div id="hostSetup">
<h2>Create a New Room</h2>
<div class="form-grid">
<div class="form-group full-width">
<label for="curriculumSelect">Curriculum:</label>
<select id="curriculumSelect"></select>
</div>
<div class="form-group"><label for="gradeSelect">Grade:</label><select id="gradeSelect" disabled></select></div>
<div class="form-group"><label for="subjectSelect">Subject:</label><select id="subjectSelect" disabled></select></div>
<div class="form-group full-width"><label for="unitSelect">Unit:</label><select id="unitSelect" disabled></select></div>
<div class="form-group full-width"><label for="conceptSelect">Concept:</label><select id="conceptSelect" disabled></select></div>
<div class="form-group"><label for="countInput">Number of Questions:</label><input type="number" id="countInput" value="5"></div>
<div class="form-group"><label>Language:</label><div><input type="checkbox" id="isArabicInput"> Arabic Quiz</div></div>
</div>
<button id="createRoomButton">Create New Room</button>
</div>
<hr style="margin: 30px 0;">
<div id="joinSetup">
<h2>Already have a Room ID?</h2>
<div class="form-group">
<label for="joinRoomIdInput">Enter Room ID:</label>
<input type="text" id="joinRoomIdInput" placeholder="e.g., ABC123">
</div>
<button id="joinRoomButton">Join Room</button>
</div>
<div class="form-group" style="margin-top: 20px;">
<label for="studentIdInput">Enter Your Student ID:</label>
<input type="text" id="studentIdInput" placeholder="e.g., student_001">
</div>
<div id="setupStatus" class="status" style="display:none;"></div>
</div>
<!-- VIEW 2: Lobby -->
<div id="lobbyView" class="view">
<div id="lobbyInfo">
<h2>Quiz Lobby</h2>
<p>Share this Room ID with other students:</p>
<div id="roomIdDisplay"></div>
<h3>Participants</h3>
<ul id="participantsList"></ul>
<p id="lobbyStatus">Waiting for the host to start the quiz...</p>
<button id="startQuizButton" style="display:none;">Start Quiz for Everyone</button>
</div>
</div>
<!-- VIEW 3: Quiz in Progress -->
<div id="quizView" class="view">
<h2>Quiz in Progress!</h2>
<p>Time: <span id="timer">0.0</span>s</p>
<form id="quizForm"></form>
</div>
<!-- VIEW 4: Results -->
<div id="resultsView" class="view">
<h2>Final Results</h2>
<table id="resultsTable" class="results-table">
<thead><tr><th>Rank</th><th>Name</th><th>Score</th><th>Time (s)</th></tr></thead>
<tbody></tbody>
</table>
<button id="playAgainButton" style="display:none;">Play Another Game</button>
</div>
</div>
<script>
// --- State Management ---
let websocket = null;
let currentRoomId = null;
let currentStudentId = null;
let quizStartTime = null;
let timerInterval = null;
let hasSubmitted = false;
// --- UI Elements ---
const views = document.querySelectorAll('.view');
const studentIdInput = document.getElementById('studentIdInput');
const setupStatus = document.getElementById('setupStatus');
// --- View Navigation ---
function showView(viewId) {
views.forEach(v => v.classList.remove('active'));
document.getElementById(viewId).classList.add('active');
}
function showError(message) {
setupStatus.textContent = message;
setupStatus.className = 'status error';
setupStatus.style.display = 'block';
}
// --- WebSocket Logic ---
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/ws/quiz/room/${currentRoomId}/${currentStudentId}`;
websocket = new WebSocket(url);
websocket.onopen = () => console.log('WebSocket connected');
websocket.onclose = () => console.log('WebSocket disconnected');
websocket.onerror = (error) => console.error('WebSocket error:', error);
websocket.onmessage = (event) => {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
};
}
function handleWebSocketMessage(message) {
console.log('Received message:', message);
switch (message.type) {
case 'participant_update':
updateLobby(message.participants, message.host_id);
break;
case 'quiz_started':
startQuiz(message.questions);
break;
case 'results_update':
updateResults(message.results, message.is_final, message.participants);
break;
}
}
// --- UI Update Functions ---
function updateLobby(participants, hostId) {
console.log('Updating lobby - Current student:', currentStudentId, 'Host:', hostId, 'Participants:', participants);
const list = document.getElementById('participantsList');
list.innerHTML = '';
const participantCount = participants ? Object.keys(participants).length : 0;
if (participantCount === 0) {
list.innerHTML = '<li>No one has joined yet...</li>';
} else {
for (const id in participants) {
const li = document.createElement('li');
let text = participants[id].name;
if (id === hostId) text += ' (Host)';
li.textContent = text;
list.appendChild(li);
}
}
const startButton = document.getElementById('startQuizButton');
const lobbyStatus = document.getElementById('lobbyStatus');
// Logic for the HOST
if (currentStudentId === hostId) {
console.log('This user is the host');
startButton.style.display = 'block';
lobbyStatus.style.display = 'none';
// Host can start with just themselves (for testing) or wait for others
if (participantCount >= 1) {
startButton.disabled = false;
if (participantCount === 1) {
startButton.textContent = 'Start Quiz (waiting for others...)';
} else {
startButton.textContent = `Start Quiz for Everyone (${participantCount} players)`;
}
} else {
startButton.disabled = true;
startButton.textContent = 'Waiting for players...';
}
}
// Logic for other PARTICIPANTS
else {
console.log('This user is a participant');
startButton.style.display = 'none';
lobbyStatus.style.display = 'block';
lobbyStatus.textContent = 'Waiting for the host to start the quiz...';
}
}
function startQuiz(questions) {
showView('quizView');
hasSubmitted = false; // Reset submission flag
const form = document.getElementById('quizForm');
form.innerHTML = '';
questions.forEach((q, i) => {
const block = document.createElement('div');
block.className = 'question-block';
block.innerHTML = `<h3>Q${i + 1}: ${q.question_text}</h3>`;
const options = [q.correct_answer, q.wrong_answer_1, q.wrong_answer_2, q.wrong_answer_3, q.wrong_answer_4].filter(Boolean);
options.sort(() => Math.random() - 0.5);
options.forEach((opt, j) => {
block.innerHTML += `
<div>
<input type="radio" id="q${i}o${j}" name="q${i}" value="${opt}" required>
<label for="q${i}o${j}">${opt}</label>
</div>`;
});
form.appendChild(block);
});
const submitButton = document.createElement('button');
submitButton.type = 'submit';
submitButton.textContent = 'Submit Answers';
form.appendChild(submitButton);
form.onsubmit = handleQuizSubmit;
quizStartTime = Date.now();
timerInterval = setInterval(() => {
document.getElementById('timer').textContent = ((Date.now() - quizStartTime) / 1000).toFixed(1);
}, 100);
}
function updateResults(results, isFinal, participants) {
// First, update the table content regardless of the view
const tableBody = document.querySelector('#resultsTable tbody');
tableBody.innerHTML = '';
results.forEach((res, index) => {
const row = tableBody.insertRow();
if (index === 0 && isFinal) row.classList.add('winner');
row.innerHTML = `
<td>${index + 1}</td>
<td>${res.name}</td>
<td>${res.score}</td>
<td>${res.time_seconds.toFixed(2)}</td>
`;
});
const header = document.querySelector('#resultsView h2');
header.textContent = isFinal ? "Final Results" : "Live Results - Waiting for others...";
// Show play again button only if final
if (isFinal) {
document.getElementById('playAgainButton').style.display = 'block';
}
// Show results if this user has submitted or the quiz is completely over
if (hasSubmitted || isFinal) {
showView('resultsView');
}
}
async function handleQuizSubmit(event) {
event.preventDefault();
clearInterval(timerInterval);
const timeTaken = (Date.now() - quizStartTime) / 1000;
hasSubmitted = true;
const formData = new FormData(event.target);
const userAnswers = {};
const questions = Array.from(document.querySelectorAll('.question-block h3'));
questions.forEach((q_element, index) => {
const questionText = q_element.textContent.substring(q_element.textContent.indexOf(':') + 2).trim();
userAnswers[questionText] = formData.get(`q${index}`);
});
websocket.send(JSON.stringify({
type: 'submit_answers',
answers: userAnswers,
time_seconds: timeTaken
}));
event.target.querySelector('button').disabled = true;
event.target.querySelector('button').textContent = 'Submitted! Waiting for results...';
// Immediately show the results view for the user who just finished
showView('resultsView');
}
// --- Reset function for playing another game ---
function resetForNewGame() {
// Close existing WebSocket if open
if (websocket) {
websocket.close();
websocket = null;
}
// Reset state
currentRoomId = null;
quizStartTime = null;
hasSubmitted = false;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
// Clear form fields except student ID
document.getElementById('joinRoomIdInput').value = '';
document.getElementById('playAgainButton').style.display = 'none';
setupStatus.style.display = 'none';
// Return to setup view
showView('setupView');
}
// --- Event Listeners & Initial Setup ---
document.getElementById('createRoomButton').addEventListener('click', async () => {
currentStudentId = studentIdInput.value.trim();
if (!currentStudentId) {
showError("Please enter your Student ID.");
return;
}
// Show "Preparing the room..." status
setupStatus.textContent = 'Preparing the room...';
setupStatus.className = 'status info';
setupStatus.style.display = 'block';
document.getElementById('createRoomButton').disabled = true;
const formData = new FormData();
formData.append('curriculum', document.getElementById('curriculumSelect').value);
formData.append('grade', document.getElementById('gradeSelect').value);
formData.append('subject', document.getElementById('subjectSelect').value);
formData.append('unit', document.getElementById('unitSelect').value);
formData.append('concept', document.getElementById('conceptSelect').value);
formData.append('is_arabic', document.getElementById('isArabicInput').checked);
formData.append('count', document.getElementById('countInput').value);
formData.append('host_id', currentStudentId);
try {
const response = await fetch('/quiz/room/create', { method: 'POST', body: formData });
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to create room.');
}
const data = await response.json();
currentRoomId = data.room_id;
document.getElementById('roomIdDisplay').textContent = currentRoomId;
// Show the start button immediately for the host (will be updated by WebSocket)
document.getElementById('startQuizButton').style.display = 'block';
document.getElementById('startQuizButton').disabled = false;
document.getElementById('startQuizButton').textContent = 'Start Quiz (waiting for others...)';
document.getElementById('lobbyStatus').style.display = 'none';
showView('lobbyView');
connectWebSocket();
} catch (err) {
showError(err.message);
} finally {
// Re-enable button and hide status
document.getElementById('createRoomButton').disabled = false;
setupStatus.style.display = 'none';
}
});
document.getElementById('joinRoomButton').addEventListener('click', async () => {
currentStudentId = studentIdInput.value.trim();
currentRoomId = document.getElementById('joinRoomIdInput').value.trim().toUpperCase();
if (!currentStudentId || !currentRoomId) {
showError("Please enter both a Room ID and your Student ID.");
return;
}
try {
const response = await fetch(`/quiz/room/${currentRoomId}`);
if (!response.ok) throw new Error('Room not found or is closed.');
document.getElementById('roomIdDisplay').textContent = currentRoomId;
showView('lobbyView');
connectWebSocket();
} catch (err) {
showError(err.message);
}
});
document.getElementById('startQuizButton').addEventListener('click', () => {
console.log('Start quiz button clicked');
if (websocket && websocket.readyState === WebSocket.OPEN) {
websocket.send(JSON.stringify({ type: 'start_quiz' }));
} else {
console.error('WebSocket is not connected');
}
});
// Play Again button event listener
document.getElementById('playAgainButton').addEventListener('click', () => {
resetForNewGame();
});
// --- Dropdown Population Logic ---
const curriculumSelect = document.getElementById('curriculumSelect');
const gradeSelect = document.getElementById('gradeSelect');
const subjectSelect = document.getElementById('subjectSelect');
const unitSelect = document.getElementById('unitSelect');
const conceptSelect = document.getElementById('conceptSelect');
const populateDropdown = (el, opts, placeholder) => {
el.innerHTML = `<option value="">-- ${placeholder} --</option>`;
// ++ ADD THIS LOGIC BLOCK ++
// Add an "All" option if it's the Unit or Concept dropdown and has items
if ((el.id === 'unitSelect' || el.id === 'conceptSelect') && opts.length > 0) {
el.innerHTML += `<option value="All">-- All --</option>`;
}
// ++ END OF ADDED BLOCK ++
opts.forEach(opt => el.innerHTML += `<option value="${opt}">${opt}</option>`);
el.disabled = opts.length === 0;
};
const fetchOptions = async (url) => {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Server error');
const data = await res.json();
return data.options || [];
} catch (e) { console.error(e); return []; }
};
document.addEventListener('DOMContentLoaded', async () => {
const curricula = await fetchOptions('/quiz/options/curricula');
populateDropdown(curriculumSelect, curricula, 'Select Curriculum');
});
curriculumSelect.addEventListener('change', async () => {
const grades = await fetchOptions(`/quiz/options/grades?curriculum=${curriculumSelect.value}`);
populateDropdown(gradeSelect, grades, 'Select Grade');
});
gradeSelect.addEventListener('change', async () => {
const subjects = await fetchOptions(`/quiz/options/subjects?curriculum=${curriculumSelect.value}&grade=${gradeSelect.value}`);
populateDropdown(subjectSelect, subjects, 'Select Subject');
});
subjectSelect.addEventListener('change', async () => {
const units = await fetchOptions(`/quiz/options/units?curriculum=${curriculumSelect.value}&grade=${gradeSelect.value}&subject=${subjectSelect.value}`);
populateDropdown(unitSelect, units, 'Select Unit');
});
unitSelect.addEventListener('change', async () => {
const concepts = await fetchOptions(`/quiz/options/concepts?curriculum=${curriculumSelect.value}&grade=${gradeSelect.value}&subject=${subjectSelect.value}&unit=${unitSelect.value}`);
populateDropdown(conceptSelect, concepts, 'Select Concept');
});
</script>
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Yourself!</title>
<style>
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; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
h1 { text-align: center; color: #2c3e50; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.form-group { display: flex; flex-direction: column; }
.form-group.full-width { grid-column: 1 / -1; }
label { margin-bottom: 5px; font-weight: bold; color: #555; }
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; }
.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; color: white; border: none; border-radius: 5px; cursor: pointer; transition: background 0.2s; }
#startButton { background: #007bff; }
#startButton:hover { background: #0056b3; }
#submitButton { background: #28a745; margin-top: 20px; }
#submitButton:hover { background: #218838; }
/* ++ Style for the new Retry Button ++ */
#retryButton { background: #6c757d; margin-top: 25px; }
#retryButton:hover { background: #5a6268; }
button:disabled { background: #95a5a6; cursor: not-allowed; }
.status { margin-top: 20px; padding: 15px; border-radius: 5px; font-weight: bold; display: none; }
.status.success { background-color: #d4edda; color: #155724; }
.status.error { background-color: #f8d7da; color: #721c24; }
.status.processing { background-color: #e7f3ff; color: #004085; }
#quizFormContainer { margin-top: 30px; border-top: 2px solid #eee; padding-top: 20px; }
.question-block { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; margin-bottom: 20px; }
.question-block h3 { margin-top: 0; color: #343a40; }
.question-block ul { list-style-type: none; padding: 0; }
.question-block li { margin: 10px 0; }
.question-block input[type="radio"] { margin-right: 10px; }
#gradeContainer { margin-top: 20px; }
.final-score { font-size: 24px; font-weight: bold; text-align: center; padding: 20px; border-radius: 5px; }
.final-score.high { background-color: #d4edda; color: #155724; }
.final-score.medium { background-color: #fff3cd; color: #856404; }
.final-score.low { background-color: #f8d7da; color: #721c24; }
.result-item { border-left: 5px solid; padding: 15px; margin-bottom: 10px; background-color: #f8f9fa; }
.result-item.correct { border-left-color: #28a745; }
.result-item.incorrect { border-left-color: #dc3545; }
.result-item p { margin: 5px 0; }
.correct-answer-text { color: #28a745; font-weight: bold; }
.user-answer-text { color: #dc3545; font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<div id="setupContainer">
<h1>Test Yourself!</h1>
<p>Select your topic to start the quiz.</p>
<div class="form-grid">
<div class="form-group full-width">
<label for="curriculumSelect">Curriculum:</label>
<select id="curriculumSelect"></select>
</div>
<div class="form-group">
<label for="gradeSelect">Grade:</label>
<select id="gradeSelect" disabled></select>
</div>
<div class="form-group">
<label for="subjectSelect">Subject:</label>
<select id="subjectSelect" disabled></select>
</div>
<div class="form-group full-width">
<label for="unitSelect">Unit:</label>
<select id="unitSelect" disabled></select>
</div>
<div class="form-group full-width">
<label for="conceptSelect">Concept:</label>
<select id="conceptSelect" disabled></select>
</div>
<div class="form-group">
<label for="countInput">Number of Questions:</label>
<input type="number" id="countInput" value="5">
</div>
<div class="form-group">
<label>Language:</label>
<div class="checkbox-group">
<input type="checkbox" id="isArabicInput">
<label for="isArabicInput">Arabic Quiz</label>
</div>
</div>
</div>
<button id="startButton">Start Test</button>
<div id="status"></div>
</div>
<div id="quizFormContainer" style="display:none;"></div>
<div id="gradeContainer" style="display:none;"></div>
</div>
<script>
// --- UI Elements ---
const setupContainer = document.getElementById('setupContainer');
const curriculumSelect = document.getElementById('curriculumSelect');
const gradeSelect = document.getElementById('gradeSelect');
const subjectSelect = document.getElementById('subjectSelect');
const unitSelect = document.getElementById('unitSelect');
const conceptSelect = document.getElementById('conceptSelect');
const countInput = document.getElementById('countInput');
const isArabicInput = document.getElementById('isArabicInput');
const startButton = document.getElementById('startButton');
const statusDiv = document.getElementById('status');
const quizFormContainer = document.getElementById('quizFormContainer');
const gradeContainer = document.getElementById('gradeContainer');
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>`;
// ++ ADD THIS LOGIC BLOCK ++
// Add an "All" option if it's the Unit or Concept dropdown and has items
if ((selectElement.id === 'unitSelect' || selectElement.id === 'conceptSelect') && options.length > 0) {
const allOpt = document.createElement('option');
allOpt.value = 'All';
allOpt.textContent = '-- All --';
selectElement.appendChild(allOpt);
}
// ++ END OF ADDED BLOCK ++
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 (same as before) ---
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 Actions ---
startButton.addEventListener('click', async () => {
const curriculum = curriculumSelect.value;
const grade = gradeSelect.value;
const subject = subjectSelect.value;
let unit = unitSelect.value;
let concept = conceptSelect.value; // May be "" initially
// Basic validation: the first three dropdowns are always required.
if (!curriculum || !grade || !subject || !unit) {
showStatus('Please select a Curriculum, Grade, Subject, and Unit.', 'error');
return;
}
// If a specific unit is chosen but no concept, default the concept to "All".
// This is the key fix for your issue.
if (unit !== 'All' && !concept) {
concept = 'All';
}
// If Unit is "All", Concept must also be "All".
if (unit === 'All') {
concept = 'All';
}
// Final check: If after all that, we still don't have a concept, it's an error.
// This case should now be rare.
if (!concept) {
showStatus('Please make a selection for the Concept.', 'error');
return;
}
showStatus('Generating your test...', 'processing');
startButton.disabled = true;
currentQuizData = [];
const formData = new FormData();
formData.append('curriculum', curriculum);
formData.append('grade', grade);
formData.append('subject', subject);
formData.append('unit', unit);
formData.append('concept', concept);
formData.append('count', countInput.value); // You can read these directly
formData.append('is_arabic', isArabicInput.checked);
try {
const response = await fetch('/quiz/dynamic', { method: 'POST', body: formData });
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: `Server error: ${response.status}`}));
throw new Error(err.detail);
}
const responseData = await response.json();
currentQuizData = responseData.quiz;
if (currentQuizData && currentQuizData.length > 0) {
setupContainer.style.display = 'none';
renderQuizForm(currentQuizData);
quizFormContainer.style.display = 'block';
} else {
showStatus('No questions could be generated for this topic. Please try different options.', 'error');
}
} catch (error) {
showStatus(`An error occurred: ${error.message}`, 'error');
} finally {
startButton.disabled = false;
}
});
function renderQuizForm(quiz) {
quizFormContainer.innerHTML = ''; // Clear previous form
const form = document.createElement('form');
form.id = 'quizForm';
quiz.forEach((question, index) => {
const questionBlock = document.createElement('div');
questionBlock.className = 'question-block';
const title = document.createElement('h3');
title.textContent = `Question ${index + 1}: ${question.question_text}`;
questionBlock.appendChild(title);
const options = [
question.correct_answer, question.wrong_answer_1, question.wrong_answer_2,
question.wrong_answer_3, question.wrong_answer_4
].filter(opt => opt).sort(() => Math.random() - 0.5); // Filter out null/empty answers and shuffle
const optionsList = document.createElement('ul');
options.forEach((optionText, optionIndex) => {
const li = document.createElement('li');
const radioId = `q${index}_opt${optionIndex}`;
const radio = document.createElement('input');
radio.type = 'radio';
radio.id = radioId;
radio.name = `question_${index}`;
radio.value = optionText;
radio.required = true;
const label = document.createElement('label');
label.htmlFor = radioId;
label.textContent = optionText;
li.appendChild(radio);
li.appendChild(label);
optionsList.appendChild(li);
});
questionBlock.appendChild(optionsList);
form.appendChild(questionBlock);
});
const submitButton = document.createElement('button');
submitButton.type = 'submit';
submitButton.id = 'submitButton';
submitButton.textContent = 'Submit Answers';
form.appendChild(submitButton);
form.addEventListener('submit', handleQuizSubmit);
quizFormContainer.appendChild(form);
}
async function handleQuizSubmit(event) {
event.preventDefault();
const form = event.target;
form.querySelector('#submitButton').disabled = true;
form.querySelector('#submitButton').textContent = 'Grading...';
const formData = new FormData(form);
const userAnswers = {};
// Map radio button values to question text
currentQuizData.forEach((question, index) => {
const selectedAnswer = formData.get(`question_${index}`);
if (selectedAnswer) {
userAnswers[question.question_text] = selectedAnswer;
}
});
const submissionPayload = {
questions: currentQuizData,
answers: userAnswers
};
try {
const response = await fetch('/quiz/grade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submissionPayload)
});
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: `Server error: ${response.status}`}));
throw new Error(err.detail);
}
const gradeData = await response.json();
quizFormContainer.style.display = 'none';
displayGrade(gradeData);
gradeContainer.style.display = 'block';
} catch (error) {
alert(`Failed to submit quiz: ${error.message}`);
form.querySelector('#submitButton').disabled = false;
form.querySelector('#submitButton').textContent = 'Submit Answers';
}
}
function displayGrade(grade) {
gradeContainer.innerHTML = '';
// Display final score
const scoreDiv = document.createElement('div');
scoreDiv.className = 'final-score';
if (grade.percentage >= 80) scoreDiv.classList.add('high');
else if (grade.percentage >= 50) scoreDiv.classList.add('medium');
else scoreDiv.classList.add('low');
scoreDiv.innerHTML = `<h2>Quiz Complete!</h2>
<p>You scored ${grade.score} out of ${grade.total_questions} (${grade.percentage}%)</p>`;
gradeContainer.appendChild(scoreDiv);
// Display detailed results
grade.results.forEach((result, index) => {
const resultDiv = document.createElement('div');
resultDiv.className = result.is_correct ? 'result-item correct' : 'result-item incorrect';
let resultHTML = `<h4>Question ${index + 1}: ${result.question_text}</h4>`;
if (result.is_correct) {
resultHTML += `<p>Your answer: <span class="correct-answer-text">${result.user_answer}</span> (Correct!)</p>`;
} else {
resultHTML += `<p>Your answer: <span class="user-answer-text">${result.user_answer}</span> (Incorrect)</p>
<p>Correct answer: <span class="correct-answer-text">${result.correct_answer}</span></p>`;
}
resultDiv.innerHTML = resultHTML;
gradeContainer.appendChild(resultDiv);
});
// ++ Add the retry button to the grade container ++
const retryButton = document.createElement('button');
retryButton.id = 'retryButton';
retryButton.textContent = 'Take Another Quiz';
retryButton.addEventListener('click', resetQuizInterface);
gradeContainer.appendChild(retryButton);
}
// ++ NEW Function to reset the interface to the start ++
function resetQuizInterface() {
// Hide the results and quiz form
gradeContainer.style.display = 'none';
quizFormContainer.style.display = 'none';
// Show the initial setup form
setupContainer.style.display = 'block';
// Hide any status messages
statusDiv.style.display = 'none';
// Clear the old quiz data
currentQuizData = [];
}
</script>
</body>
</html>
from .utils import DateTimeEncoder
\ No newline at end of file
from datetime import datetime
import json
class DateTimeEncoder(json.JSONEncoder):
""" Custom JSON encoder to handle datetime objects """
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)
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