diff --git a/backend/delete_collection.py b/backend/delete_collection.py new file mode 100644 index 0000000..d03ff8f --- /dev/null +++ b/backend/delete_collection.py @@ -0,0 +1,39 @@ +""" +Script to delete the Qdrant collection and start fresh. +Run this before re-ingesting to avoid duplicates. +""" +import os +from dotenv import load_dotenv +from src.services.qdrant_service import get_qdrant_client + +load_dotenv() + +def delete_collection(): + """Delete the book_content_chunks collection.""" + try: + client = get_qdrant_client() + collection_name = "book_content_chunks" + + if client.collection_exists(collection_name=collection_name): + print(f"šŸ—‘ļø Deleting collection '{collection_name}'...") + client.delete_collection(collection_name=collection_name) + print(f"āœ… Collection '{collection_name}' deleted successfully!") + print(f"šŸ“ You can now run ingestion to create fresh data with language tags.") + else: + print(f"ā„¹ļø Collection '{collection_name}' does not exist. Nothing to delete.") + + except Exception as e: + print(f"āŒ Error: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + print("šŸ” Qdrant Collection Cleanup") + print("=" * 80) + + response = input("āš ļø This will delete ALL data in the collection. Continue? (yes/no): ") + + if response.lower() in ['yes', 'y']: + delete_collection() + else: + print("āŒ Operation cancelled.") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e0721f0..dfa87e4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,18 +5,26 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "aiosqlite>=0.21.0", + "aiohttp>=3.13.3", + "aiosqlite>=0.22.1", "asyncpg>=0.31.0", - "fastapi>=0.124.0", + "bcrypt>=5.0.0", + "fastapi>=0.128.6", + "fastapi-limiter>=0.2.0", "fastembed>=0.7.4", + "google-generativeai>=0.8.6", + "groq>=1.0.0", "httpx>=0.28.1", - "mangum>=0.19.0", - "openai>=2.9.0", + "openai>=2.18.0", "passlib[bcrypt]>=1.7.4", + "psycopg2>=2.9.11", "pydantic>=2.12.5", "pytest>=9.0.2", "python-dotenv>=1.2.1", - "qdrant-client>=1.16.1", - "sqlmodel>=0.0.27", - "uvicorn>=0.38.0", + "python-jose[cryptography]>=3.5.0", + "qdrant-client>=1.16.2", + "redis>=7.1.1", + "sqlalchemy>=2.0.46", + "sqlmodel>=0.0.32", + "uvicorn>=0.40.0", ] diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index ce67c15..593874b 100644 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -13,7 +13,6 @@ ErrorResponse ) from ..services.auth_service import auth_service -from fastapi_limiter.depends import RateLimiter # Import RateLimiter auth_router = APIRouter(prefix="/auth", tags=["auth"]) diff --git a/backend/src/api/chat.py b/backend/src/api/chat.py index f854f08..0919d5f 100644 --- a/backend/src/api/chat.py +++ b/backend/src/api/chat.py @@ -1,17 +1,26 @@ import traceback +import re from fastapi import APIRouter, HTTPException from pydantic import BaseModel from typing import List, Optional +from groq import Groq +import os +from dotenv import load_dotenv -from ..services.embedding_service import get_embeddings, get_gemini_embedding_client +from ..services.embedding_service import get_embeddings from ..services.qdrant_service import get_qdrant_client -from .ingest import DocumentChunk # Reuse DocumentChunk model -from dotenv import load_dotenv +from .ingest import DocumentChunk load_dotenv() router = APIRouter() +groq_client = Groq(api_key=os.getenv("GROQ_API_KEY")) + +# ------------------------- +# Models +# ------------------------- + class Citation(BaseModel): doc_id: str chunk_id: str @@ -26,28 +35,75 @@ class ChatResponse(BaseModel): answer: str citations: List[Citation] = [] -@router.post("/chat", response_model=ChatResponse) -async def chat_with_bot(request: ChatRequest): + +# ------------------------- +# Utility: Clean Context +# ------------------------- + +def clean_context(text: str) -> str: """ - Interacts with the RAG chatbot, retrieves context, and provides answers with citations. + Remove markdown, frontmatter, JSX components, + and Docusaurus formatting before sending to LLM. """ + # Remove frontmatter (--- title ---) + text = re.sub(r"---.*?---", "", text, flags=re.DOTALL) + + # Remove import lines + text = re.sub(r"import .*", "", text) + + # Remove JSX components like + text = re.sub(r"<.*?>", "", text) + + # Remove ::: blocks (tip, danger, etc.) + text = re.sub(r":::[\s\S]*?:::", "", text) + + # Remove markdown symbols + text = re.sub(r"[#>*`]", "", text) + + # Remove extra whitespace + text = re.sub(r"\n\s*\n", "\n\n", text) + + return text.strip() + + +# ------------------------- +# Chat Endpoint +# ------------------------- + +@router.post("/chat", response_model=ChatResponse) +async def chat_with_bot(request: ChatRequest): try: + # ------------------------- + # 1ļøāƒ£ Basic Query Guard + # ------------------------- + if len(request.query.strip().split()) < 3: + return ChatResponse( + answer="Hi, there. Please ask a question related to the book's content.", + citations=[] + ) + qdrant_client = get_qdrant_client() collection_name = "book_content_chunks" if not qdrant_client.collection_exists(collection_name=collection_name): - raise HTTPException(status_code=404, detail=f"Qdrant collection '{collection_name}' not found. Please ingest documents first.") + raise HTTPException( + status_code=404, + detail=f"Qdrant collection '{collection_name}' not found. Please ingest documents first." + ) - # Determine the text to use for retrieval + # ------------------------- + # 2ļøāƒ£ Create Embedding + # ------------------------- retrieval_query_text = request.selected_text if request.selected_text else request.query - query_embedding = get_embeddings([retrieval_query_text])[0] - # Retrieve relevant chunks from Qdrant + # ------------------------- + # 3ļøāƒ£ Retrieve Top Chunks + # ------------------------- search_result = qdrant_client.query_points( collection_name=collection_name, - query=query_embedding, # Use query_embedding as the vector argument - limit=3, # Retrieve top N relevant documents for context + query=query_embedding, + limit=2, # Reduced from 3 to prevent token overflow with_payload=True ) @@ -58,51 +114,67 @@ async def chat_with_bot(request: ChatRequest): for scored_point in search_result.points: if scored_point.payload: chunk = DocumentChunk(**scored_point.payload) + context_chunks.append(chunk) + citations.append(Citation( - doc_id=chunk.chapter_title, # Using chapter_title as doc_id for now - chunk_id=f"{chunk.url_slug}-{scored_point.id}", # Unique ID for each chunk - url=f"/docs{chunk.url_slug}" # Assuming Docusaurus URL structure + doc_id=chunk.chapter_title, + chunk_id=f"{chunk.url_slug}-{scored_point.id}", + url=f"/docs{chunk.url_slug}" )) - # Construct prompt for the LLM - context_text = "\n\n".join([chunk.page_content for chunk in context_chunks]) - - # User-specified instruction: "in chatbot the agent must only get the query if its is related to book or its content otherwise aplogy reply" - # This will be handled by the prompt engineering + # ------------------------- + # 4ļøāƒ£ Build Clean Context + # ------------------------- + context_text = "\n\n".join([ + clean_context(chunk.page_content) + for chunk in context_chunks + ]) + if not context_text: - return ChatResponse(answer="I apologize, but I couldn't find any relevant information in the book to answer your question. Please try rephrasing or ask a question directly related to the book's content.", citations=[]) + return ChatResponse( + answer="I apologize, but I couldn't find relevant information in the book.", + citations=[] + ) + # ------------------------- + # 5ļøāƒ£ HARD TOKEN SAFETY LIMIT + # ------------------------- + MAX_CONTEXT_CHARS = 12000 # ~3000 tokens approx + context_text = context_text[:MAX_CONTEXT_CHARS] + # ------------------------- + # 6ļøāƒ£ Construct Prompt + # ------------------------- prompt_messages = [ - {"role": "system", "content": "You are a helpful book assistant. Answer the user's question ONLY based on the provided context from the book. If the question cannot be answered from the context, politely state that you cannot answer it and suggest asking a question related to the book's content. Do not use outside knowledge."}, - {"role": "user", "content": f"Context from the book:\n{context_text}\n\nUser Question: {request.query}"} + { + "role": "system", + "content": "You are a helpful book assistant. Answer only from the provided context. If the answer is not in the context, say you cannot answer." + }, + { + "role": "user", + "content": f"Context:\n{context_text}\n\nQuestion: {request.query}" + } ] - - # Get Gemini LLM client configured via OpenAI client - gemini_llm_client = get_gemini_embedding_client() # Reusing the same client setup, but will call chat.completions - - # For chat completion, Gemini uses models like "gemini-pro" or "gemini-1.5-pro" - # I'll use "gemini-1.5-pro" as it's a capable model. - # This might need adjustment based on available models via the OpenAI-compatible endpoint. - try: - chat_response = gemini_llm_client.chat.completions.create( - model="gemini-2.5-flash-lite", # Use an appropriate Gemini chat model - messages=prompt_messages, - temperature=0.7, - max_tokens=500 - ) - answer = chat_response.choices[0].message.content - except Exception as llm_error: - # Fallback for polite refusal if LLM call fails - print(f"LLM call failed: {llm_error}") - return ChatResponse(answer="I apologize, but I couldn't process your request at the moment. Please try again later.", citations=[]) + + # ------------------------- + # 7ļøāƒ£ Call Groq + # ------------------------- + chat_response = groq_client.chat.completions.create( + model="llama-3.1-8b-instant", + messages=prompt_messages, + temperature=0.5, + max_tokens=500 + ) + + answer = chat_response.choices[0].message.content return ChatResponse(answer=answer, citations=citations) except HTTPException as e: raise e + except Exception as e: - print(f"An unexpected error occurred: {e}") - traceback.print_exc() # Print the full traceback - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + print(f"Unexpected error: {e}") + traceback.print_exc() + raise HTTPException(status_code=500, detail="Internal server error.") diff --git a/backend/src/api/ingest.py b/backend/src/api/ingest.py index 689331c..8cf7ef7 100644 --- a/backend/src/api/ingest.py +++ b/backend/src/api/ingest.py @@ -3,7 +3,9 @@ import os import glob import uuid +import time from typing import List +import anyio from ..services.embedding_service import get_embeddings from ..services.qdrant_service import get_qdrant_client @@ -20,16 +22,27 @@ class DocumentChunk(BaseModel): page_content: str = Field(..., max_length=CHUNK_MAX_LENGTH) # Reduced from 5000 to 1500 for better performance and context chapter_title: str url_slug: str + language: str = Field(default="en", description="Language code: 'en' for English, 'ur' for Urdu") class Config: json_schema_extra = { "example": { "page_content": "This is a chunk of text.", "chapter_title": "Introduction", - "url_slug": "/docs/intro" + "url_slug": "/docs/intro", + "language": "en" } } + +async def _read_file_async(file_path: str) -> str: + """Read file asynchronously using anyio.to_thread.run_sync.""" + def _read(): + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() + return await anyio.to_thread.run_sync(_read) + + @router.post("/ingest") async def ingest_docs(): """ @@ -57,8 +70,7 @@ async def ingest_docs(): all_chunks = [] for file_path in markdown_files: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() + content = await _read_file_async(file_path) if not content.strip(): print(f"Warning: Skipping empty file {file_path}") @@ -93,22 +105,47 @@ async def ingest_docs(): relative_path = os.path.relpath(file_path, docs_path) url_slug = "/" + os.path.splitext(relative_path)[0].replace("\\", "/") - + chapter_title = os.path.basename(file_path).replace(".md", "").replace(".mdx", "") if chunk_content_stripped.startswith("#"): chapter_title = chunk_content_stripped.split('\n')[0].replace("#", "").strip() + # Detect language based on filename + filename = os.path.basename(file_path) + language = "ur" if filename.startswith("urdu-") else "en" + all_chunks.append(DocumentChunk( page_content=chunk_content_stripped, chapter_title=chapter_title, - url_slug=url_slug + url_slug=url_slug, + language=language )) - + if not all_chunks: return {"message": "No content to ingest.", "documents_ingested": 0} + # Process embeddings in batches to avoid rate limits texts_to_embed = [chunk.page_content for chunk in all_chunks] - embeddings = get_embeddings(texts_to_embed) + embeddings = [] + batch_size = 10 # Process 10 chunks at a time + total_batches = (len(texts_to_embed) + batch_size - 1) // batch_size + + print(f"šŸ“Š Processing {len(texts_to_embed)} chunks in {total_batches} batches...") + + for i in range(0, len(texts_to_embed), batch_size): + batch_num = (i // batch_size) + 1 + batch_texts = texts_to_embed[i:i + batch_size] + + print(f"šŸ”„ Processing batch {batch_num}/{total_batches} ({len(batch_texts)} chunks)...") + batch_embeddings = get_embeddings(batch_texts) + embeddings.extend(batch_embeddings) + + # Wait 60 seconds between batches (except after the last batch) + if i + batch_size < len(texts_to_embed): + print(f"ā³ Waiting 60 seconds before next batch to avoid rate limits...") + time.sleep(60) + + print(f"āœ… All embeddings generated successfully!") qdrant_client = get_qdrant_client() collection_name = "book_content_chunks" @@ -154,4 +191,4 @@ async def ingest_docs(): except HTTPException as e: raise e except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/backend/src/api/query.py b/backend/src/api/query.py index 634ea7f..f1858c6 100644 --- a/backend/src/api/query.py +++ b/backend/src/api/query.py @@ -31,7 +31,7 @@ async def query_docs(request: QueryRequest): if not qdrant_client.collection_exists(collection_name=collection_name): raise HTTPException(status_code=404, detail=f"Qdrant collection '{collection_name}' not found. Please ingest documents first.") - query_embedding = get_embeddings(request.query) + query_embedding = get_embeddings([request.query]) search_result = qdrant_client.query( collection_name=collection_name, diff --git a/backend/src/main.py b/backend/src/main.py index 10d0fe2..ac855e1 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -5,21 +5,25 @@ from .api.auth import auth_router from dotenv import load_dotenv import os -from fastapi_limiter import FastAPILimiter -import redis.asyncio as redis # Using async Redis client -from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import Response -from starlette.types import ASGIApp - -load_dotenv() - +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded +limiter = Limiter(key_func=get_remote_address) app = FastAPI( title="RAG Chatbot API", description="API for the RAG chatbot that answers questions based on book content", version="1.0.0" ) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response +from starlette.types import ASGIApp + +load_dotenv() origins = [ "https://physical-ai-n-humanoid-robotics.vercel.app", # Without trailing slash @@ -56,19 +60,6 @@ async def dispatch(self, request: Request, call_next): app.add_middleware(HTTPSRedirectMiddleware) app.add_middleware(SecurityHeadersMiddleware) -# Add a startup event handler for FastAPILimiter -@app.on_event("startup") -async def startup_event(): - try: - # Initialize Redis client for rate limiting - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") - r = redis.from_url(redis_url, encoding="utf8", decode_responses=True) - await FastAPILimiter.init(r) - except Exception as e: - print(f"Warning: Could not initialize Redis for rate limiting: {e}") - print("Rate limiting will be disabled.") - - # Include API routes app.include_router(ingest.router, prefix="/api/v1", tags=["ingest"]) app.include_router(query.router, prefix="/api/v1", tags=["query"]) diff --git a/backend/src/services/embedding_service.py b/backend/src/services/embedding_service.py index 9d1bea8..b7f3106 100644 --- a/backend/src/services/embedding_service.py +++ b/backend/src/services/embedding_service.py @@ -7,6 +7,8 @@ def get_gemini_embedding_client() -> OpenAI: gemini_api_key = os.getenv("GEMINI_API_KEY") + if gemini_api_key: + gemini_api_key = gemini_api_key.strip() # Strip whitespace, including newlines # Use the Google Generative Language API endpoint for OpenAI compatibility gemini_base_url = os.getenv("GEMINI_BASE_URL", "https://generativelanguage.googleapis.com/v1beta/openai/") @@ -23,7 +25,7 @@ def get_embeddings(texts: list[str]) -> list[list[float]]: client = get_gemini_embedding_client() # Using a common Gemini embedding model identifier # If this fails, consult Google's latest documentation - model = "text-embedding-004" + model = "gemini-embedding-001" response = client.embeddings.create( input=texts, diff --git a/backend/src/services/gemini_personalization_service.py b/backend/src/services/gemini_personalization_service.py index 65e761c..edb6807 100644 --- a/backend/src/services/gemini_personalization_service.py +++ b/backend/src/services/gemini_personalization_service.py @@ -1,20 +1,18 @@ """ Gemini-based Personalization Service for adapting content based on user profile -Uses Google's Gemini API to intelligently simplify or enhance content while maintaining syllabus compliance +Uses Groq API to intelligently simplify or enhance content while maintaining syllabus compliance """ import os -try: - import google.generativeai as genai - GENAI_AVAILABLE = True -except ImportError: - genai = None - GENAI_AVAILABLE = False +from groq import Groq from typing import Dict, Any, Optional from ..utils.logging_utils import log_info, log_warning, log_error from dotenv import load_dotenv load_dotenv() +GROQ_API_KEY = os.getenv("GROQ_API_KEY") +GROQ_MODEL_NAME = os.getenv("GROQ_MODEL_NAME", "llama-3.1-8b-instant") # Default Groq model + class SyllabusComplianceValidator: """ @@ -76,20 +74,20 @@ class GeminiPersonalizationService: def __init__(self): self.validator = SyllabusComplianceValidator() self.available = False - if GENAI_AVAILABLE and genai: - GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") - if GEMINI_API_KEY: - try: - genai.configure(api_key=GEMINI_API_KEY) - self.model = genai.GenerativeModel('gemini-2.5-flash-lite') - self.available = True - log_info("Google GenAI configured successfully") - except Exception as e: - log_warning(f"Failed to configure Google GenAI: {str(e)}", "Configuration") - else: - log_warning("GEMINI_API_KEY not found", "Configuration") + self.groq_client = None + + if GROQ_API_KEY: + try: + self.groq_client = Groq(api_key=GROQ_API_KEY) + # Test a simple call to ensure the client is working + # This part is illustrative, and might need actual implementation depending on Groq API's init behavior + # For now, just setting self.available to True if client is initialized + self.available = True + log_info("Groq client configured successfully") + except Exception as e: + log_warning(f"Failed to configure Groq client: {str(e)}", "Configuration") else: - log_warning("google.generativeai library not found", "Configuration") + log_warning("GROQ_API_KEY not found", "Configuration") # System instruction for maintaining syllabus compliance self.syllabus_context = """ @@ -114,13 +112,13 @@ def simplify_for_beginners(self, content: str, tech_background: str = "general") Simplify content for users with beginner experience level Adds more explanations, breaks down complex concepts, uses simpler language """ - if not self.available: - log_warning("GenAI not available, returning original content", "simplify_for_beginners") + if not self.available or not self.groq_client: + log_warning("Groq client not available, returning original content", "simplify_for_beginners") return content - prompt = f""" -{self.syllabus_context} - + messages = [ + {"role": "system", "content": self.syllabus_context}, + {"role": "user", "content": f""" TASK: Adapt this Physical AI & Robotics textbook content for BEGINNER-level learners. User's technical background: {tech_background} @@ -140,11 +138,17 @@ def simplify_for_beginners(self, content: str, tech_background: str = "general") {content} ADAPTED CONTENT (maintain MDX format): -""" +"""} + ] try: - response = self.model.generate_content(prompt) - personalized_content = response.text + chat_completion = self.groq_client.chat.completions.create( + messages=messages, + model=GROQ_MODEL_NAME, + temperature=0.7, # You can adjust temperature as needed + max_tokens=2000, # Adjust max_tokens as needed + ) + personalized_content = chat_completion.choices[0].message.content # Validate syllabus compliance is_valid, message = self.validator.validate_content(content, personalized_content) @@ -166,13 +170,13 @@ def enhance_for_advanced(self, content: str, tech_background: str = "advanced") Enhance content for users with advanced experience level Adds technical depth, advanced concepts, implementation details """ - if not self.available: - log_warning("GenAI not available, returning original content", "enhance_for_advanced") + if not self.available or not self.groq_client: + log_warning("Groq client not available, returning original content", "enhance_for_advanced") return content - prompt = f""" -{self.syllabus_context} - + messages = [ + {"role": "system", "content": self.syllabus_context}, + {"role": "user", "content": f""" TASK: Adapt this Physical AI & Robotics textbook content for ADVANCED-level learners. User's technical background: {tech_background} @@ -192,11 +196,17 @@ def enhance_for_advanced(self, content: str, tech_background: str = "advanced") {content} ENHANCED CONTENT (maintain MDX format): -""" +"""} + ] try: - response = self.model.generate_content(prompt) - personalized_content = response.text + chat_completion = self.groq_client.chat.completions.create( + messages=messages, + model=GROQ_MODEL_NAME, + temperature=0.7, # You can adjust temperature as needed + max_tokens=2000, # Adjust max_tokens as needed + ) + personalized_content = chat_completion.choices[0].message.content # Validate syllabus compliance is_valid, message = self.validator.validate_content(content, personalized_content) @@ -221,8 +231,8 @@ def apply_user_preferences( """ Apply specific user preferences to content adaptation """ - if not self.available: - log_warning("GenAI not available, returning original content", "apply_user_preferences") + if not self.available or not self.groq_client: + log_warning("Groq client not available, returning original content", "apply_user_preferences") return content preference_instructions = [] @@ -244,9 +254,9 @@ def apply_user_preferences( if not preference_instructions: return content - prompt = f""" -{self.syllabus_context} - + messages = [ + {"role": "system", "content": self.syllabus_context}, + {"role": "user", "content": f""" TASK: Adapt this content with the following user preferences: {chr(10).join(f'- {instruction}' for instruction in preference_instructions)} @@ -256,11 +266,17 @@ def apply_user_preferences( {content} ADAPTED CONTENT (maintain MDX format): -""" +"""} + ] try: - response = self.model.generate_content(prompt) - personalized_content = response.text + chat_completion = self.groq_client.chat.completions.create( + messages=messages, + model=GROQ_MODEL_NAME, + temperature=0.7, # You can adjust temperature as needed + max_tokens=2000, # Adjust max_tokens as needed + ) + personalized_content = chat_completion.choices[0].message.content # Validate syllabus compliance is_valid, message = self.validator.validate_content(content, personalized_content) diff --git a/backend/src/utils/content_utils.py b/backend/src/utils/content_utils.py index 54dee0a..4afac3d 100644 --- a/backend/src/utils/content_utils.py +++ b/backend/src/utils/content_utils.py @@ -6,6 +6,24 @@ import shutil from typing import Optional import re +import anyio + + +async def _read_file_async(file_path: str) -> str: + """Read file asynchronously using anyio.to_thread.run_sync.""" + def _read(): + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() + return await anyio.to_thread.run_sync(_read) + + +async def _write_file_async(file_path: str, content: str) -> None: + """Write file asynchronously using anyio.to_thread.run_sync.""" + def _write(): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + return await anyio.to_thread.run_sync(_write) def get_content_directory(): @@ -19,6 +37,7 @@ def get_content_directory(): content_dir = os.path.abspath(os.path.join(project_root, "book_frontend", "docs")) return content_dir + async def read_content_file(chapter_id: str) -> str: """ Read content from an MDX file based on chapter ID @@ -89,8 +108,7 @@ async def read_content_file(chapter_id: str) -> str: # Try each possible path in local environment for file_path in possible_paths: if os.path.exists(file_path): - with open(file_path, 'r', encoding='utf-8') as file: - content = file.read() + content = await _read_file_async(file_path) return content # If file is not found locally, try to fetch from the deployed frontend @@ -139,6 +157,7 @@ async def read_content_file(chapter_id: str) -> str: # If no file found locally or remotely, raise error with helpful message raise FileNotFoundError(f"Content file not found for chapter: {chapter_id}. Tried paths: {possible_paths}") + async def write_content_file(file_name: str, content: str) -> bool: """ Write content to an MDX file @@ -149,15 +168,14 @@ async def write_content_file(file_name: str, content: str) -> bool: try: # Create directory if it doesn't exist os.makedirs(os.path.dirname(file_path), exist_ok=True) - - with open(file_path, 'w', encoding='utf-8') as file: - file.write(content) - + + await _write_file_async(file_path, content) return True except Exception as e: print(f"Error writing content file {file_path}: {str(e)}") return False + async def sync_urdu_content(english_file_path: str, urdu_content: str = None) -> bool: """ Synchronize Urdu translation with English content @@ -185,33 +203,27 @@ async def sync_urdu_content(english_file_path: str, urdu_content: str = None) -> try: if urdu_content is not None: - # Write provided Urdu content to the Urdu file - os.makedirs(os.path.dirname(urdu_file_path), exist_ok=True) - with open(urdu_file_path, 'w', encoding='utf-8') as file: - file.write(urdu_content) + # Write provided Urdu content to the Urdu file + await _write_file_async(urdu_file_path, urdu_content) return True else: # Create Urdu file based on English content if it doesn't exist if not os.path.exists(urdu_file_path): # Read English content - with open(english_file_path, 'r', encoding='utf-8') as file: - english_content = file.read() + english_content = await _read_file_async(english_file_path) # Create basic Urdu content (initially just a placeholder) # In a real implementation, this would involve actual translation urdu_content = f"\n\n{english_content}" - # Write to Urdu file - os.makedirs(os.path.dirname(urdu_file_path), exist_ok=True) - with open(urdu_file_path, 'w', encoding='utf-8') as file: - file.write(urdu_content) - + await _write_file_async(urdu_file_path, urdu_content) return True return True # Urdu file already exists except Exception as e: print(f"Error synchronizing Urdu content for {english_file_path}: {str(e)}") return False + async def get_urdu_file_path(english_file_path: str) -> str: """ Get the corresponding Urdu file path for an English file @@ -232,6 +244,7 @@ async def get_urdu_file_path(english_file_path: str) -> str: name, ext = os.path.splitext(base_name) return os.path.join(dir_path, f"urdu-{name}{ext}") + async def check_urdu_file_exists(english_file_path: str) -> bool: """ Check if the corresponding Urdu file exists for an English file @@ -239,6 +252,7 @@ async def check_urdu_file_exists(english_file_path: str) -> bool: urdu_file_path = await get_urdu_file_path(english_file_path) return os.path.exists(urdu_file_path) + async def get_all_content_files() -> list: """ Get all content files in the docs directory @@ -257,6 +271,7 @@ async def get_all_content_files() -> list: return content_files + async def sync_all_urdu_files() -> bool: """ Synchronize all Urdu translation files with their English counterparts diff --git a/book_frontend/src/components/Auth/AuthModal.js b/book_frontend/src/components/Auth/AuthModal.js index f83a926..893114b 100644 --- a/book_frontend/src/components/Auth/AuthModal.js +++ b/book_frontend/src/components/Auth/AuthModal.js @@ -33,9 +33,39 @@ const AuthModal = ({ isOpen, onClose, initialView = 'login', onLoginSuccess, onS } }; + const handleOverlayKeyDown = (e) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + const handleContentKeyDown = (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + } + }; + return ( -
-
e.stopPropagation()}> +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClose(); + } + }} + tabIndex={0} + role="button" + aria-label="Close modal" + > +
e.stopPropagation()} + onKeyDown={(e) => e.key === 'Escape' && e.stopPropagation()} + tabIndex={-1} + role="dialog" + >

{currentView === 'login' ? 'Sign In' : 'Create Account'}