Spaces:
Sleeping
Sleeping
Enhance AI Concept Explainer with version 2.0.0: Introduced new features including real-time reading time estimates, a character counter, and a copy-to-clipboard function. Improved UI with a modern gradient header and responsive design. Added structured logging and comprehensive error handling for better user experience.
7997e54
| """ | |
| AI Concept Explainer | |
| =================== | |
| Gradio app that explains concepts at different complexity levels | |
| and in multiple languages using OpenAI's GPT-4.1-mini. | |
| Features: | |
| - 5 complexity levels (age 5 to expert) | |
| - 6 languages supported | |
| - Real-time streaming responses | |
| - Copy to clipboard & download | |
| - Session history tracking | |
| - Reading time estimates | |
| - Keyboard shortcuts | |
| """ | |
| import os | |
| import signal | |
| import logging | |
| from typing import Generator, Tuple, Dict, List, Optional | |
| from datetime import datetime | |
| import gradio as gr | |
| from openai import OpenAI | |
| from dotenv import load_dotenv | |
| # ==================== CONFIGURATION ==================== | |
| VERSION = "2.0.0" | |
| APP_NAME = "AI Concept Explainer" | |
| MAX_QUESTION_LENGTH = 500 | |
| # Setup logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Load environment variables | |
| if not os.getenv("SPACE_ID"): # Not in HF Spaces | |
| try: | |
| load_dotenv('.env') | |
| except Exception as e: | |
| logger.warning(f"Could not load .env file: {e}") | |
| # Initialize OpenAI client | |
| openai_api_key = os.getenv("OPENAI_API_KEY") | |
| if not openai_api_key: | |
| raise ValueError("OPENAI_API_KEY not found. Please set it in your environment variables or .env file.") | |
| client = OpenAI( | |
| api_key=openai_api_key, | |
| timeout=60.0, | |
| max_retries=3 | |
| ) | |
| # Explanation levels | |
| EXPLANATION_LEVELS: Dict[int, str] = { | |
| 1: "like I'm 5 years old - use simple words and analogies", | |
| 2: "like I'm 10 years old - basic concepts with examples", | |
| 3: "like a high school student - intermediate level with some technical terms", | |
| 4: "like a college student - advanced concepts with detailed explanations", | |
| 5: "like an expert in the field - professional level with technical depth", | |
| } | |
| LANGUAGES: List[str] = ["English", "Russian", "German", "Spanish", "French", "Italian"] | |
| # Example questions | |
| EXAMPLE_QUESTIONS = [ | |
| "Why is the sky blue?", | |
| "How does the internet work?", | |
| "What is artificial intelligence?" | |
| ] | |
| # ==================== HELPER FUNCTIONS ==================== | |
| def estimate_reading_time(text: str) -> str: | |
| """ | |
| Estimate reading time for given text. | |
| Args: | |
| text: The text to estimate reading time for | |
| Returns: | |
| Formatted reading time string | |
| """ | |
| words = len(text.split()) | |
| # Average reading speed: 200-250 words per minute | |
| minutes = words / 225 | |
| if minutes < 1: | |
| seconds = int(minutes * 60) | |
| return f"~{seconds}s read" | |
| else: | |
| return f"~{int(minutes)}min read" | |
| # ==================== MAIN FUNCTIONS ==================== | |
| def explain_concept(question: str, level: int, language: str) -> Generator[Tuple[str, str], None, None]: | |
| """ | |
| Generate an explanation for a given concept with streaming. | |
| Args: | |
| question: The concept or question to explain | |
| level: Complexity level (1-5) | |
| language: Language for the explanation | |
| Yields: | |
| Tuple of (explanation, reading_time) | |
| """ | |
| if not question.strip(): | |
| error_msg = "β Please enter a concept to explain." | |
| yield (error_msg, "") | |
| return | |
| if len(question) > MAX_QUESTION_LENGTH: | |
| error_msg = f"β Question too long. Please limit to {MAX_QUESTION_LENGTH} characters." | |
| yield (error_msg, "") | |
| return | |
| level_desc = EXPLANATION_LEVELS.get(level, "clearly and comprehensively") | |
| if language not in LANGUAGES: | |
| language = "English" | |
| logger.warning(f"Invalid language selected, defaulting to English") | |
| system_prompt = f"""You are an expert educator. Explain the given concept {level_desc} in {language}. | |
| Guidelines: | |
| - Provide a clear, accurate response in 150-250 words | |
| - Use **bold** to emphasize key points | |
| - Be concise and engaging | |
| - Focus on the explanation without introductions or conclusions | |
| - Use examples or analogies where helpful""" | |
| try: | |
| logger.info(f"Generating explanation for: '{question[:50]}...' at level {level} in {language}") | |
| stream = client.chat.completions.create( | |
| model="gpt-4.1-mini", # Using the correct model name | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": question} | |
| ], | |
| temperature=0.7, | |
| max_tokens=1000, | |
| stream=True | |
| ) | |
| partial = "" | |
| for chunk in stream: | |
| delta = getattr(chunk.choices[0].delta, "content", None) | |
| if delta: | |
| partial += delta | |
| reading_time = estimate_reading_time(partial) | |
| yield (partial, f"π {reading_time}") | |
| # Log completion | |
| if partial: | |
| logger.info(f"Successfully generated explanation ({len(partial)} chars)") | |
| except Exception as e: | |
| error_msg = str(e).lower() | |
| error_type = type(e).__name__ | |
| logger.error(f"API Error [{error_type}]: {str(e)}") | |
| if "connection" in error_msg or "timeout" in error_msg or "ssl" in error_msg: | |
| error_display = f"""β **Connection Error**: Unable to reach OpenAI API. | |
| Please check your internet connection and try again. | |
| <details><summary>Technical details</summary>{error_type}: {str(e)[:200]}</details>""" | |
| elif "api key" in error_msg or "authentication" in error_msg or "401" in error_msg: | |
| error_display = """β **Authentication Error**: Invalid or missing API key. | |
| Please contact the administrator.""" | |
| elif "rate limit" in error_msg or "429" in error_msg: | |
| error_display = """β **Rate Limit Exceeded**: Too many requests. | |
| Please wait a moment and try again. If you're using this frequently, consider upgrading your API plan.""" | |
| elif "model" in error_msg or "404" in error_msg: | |
| error_display = f"""β **Model Error**: The AI model may not be available. | |
| Please try again later or contact support. | |
| <details><summary>Technical details</summary>{str(e)}</details>""" | |
| else: | |
| error_display = f"""β **Unexpected Error**: Something went wrong. | |
| Please try again. If the problem persists, contact support. | |
| <details><summary>Technical details</summary>[{error_type}] {str(e)}</details>""" | |
| yield (error_display, "") | |
| def update_char_count(text: str) -> str: | |
| """Update character counter display.""" | |
| count = len(text) | |
| remaining = MAX_QUESTION_LENGTH - count | |
| if count == 0: | |
| color = "#666" | |
| status = "" | |
| elif count > MAX_QUESTION_LENGTH: | |
| color = "#ef4444" | |
| status = " β οΈ Too long!" | |
| elif count > MAX_QUESTION_LENGTH * 0.9: | |
| color = "#f59e0b" | |
| status = " β οΈ" | |
| else: | |
| color = "#10b981" | |
| status = " β" | |
| return f'<span style="color: {color}; font-weight: 500;">{count}/{MAX_QUESTION_LENGTH}{status}</span>' | |
| def clear_inputs() -> Tuple[str, int, str]: | |
| """Clear all input fields.""" | |
| return ("", 3, "English") | |
| # ==================== GRADIO INTERFACE ==================== | |
| # Enhanced CSS with modern styling | |
| custom_css = """ | |
| /* ==================== GLOBAL STYLES ==================== */ | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); | |
| .gradio-container { | |
| max-width: 900px !important; | |
| margin: auto !important; | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; | |
| } | |
| /* ==================== HEADER STYLES ==================== */ | |
| .hero-section { | |
| text-align: center; | |
| padding: 15px 20px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| border-radius: 10px; | |
| color: white; | |
| margin-bottom: 20px; | |
| box-shadow: 0 2px 10px rgba(102, 126, 234, 0.15); | |
| } | |
| .hero-title { | |
| font-size: 1.6em; | |
| font-weight: 700; | |
| margin-bottom: 5px; | |
| } | |
| .hero-subtitle { | |
| font-size: 0.9em; | |
| opacity: 0.95; | |
| margin: 0; | |
| } | |
| /* ==================== EXPLANATION BOX ==================== */ | |
| .explanation-box { | |
| background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 100%) !important; | |
| color: #1a1a1a !important; | |
| padding: 18px; | |
| border-radius: 10px; | |
| margin: 8px 0; | |
| border: 1px solid #d0d7f7; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.1); | |
| min-height: 100px; | |
| transition: all 0.3s ease; | |
| } | |
| .explanation-box:hover { | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.15); | |
| } | |
| .explanation-box * { | |
| color: #1a1a1a !important; | |
| line-height: 1.7; | |
| } | |
| .explanation-box strong, | |
| .explanation-box b { | |
| color: #6366f1 !important; | |
| font-weight: 600; | |
| } | |
| /* Dark mode */ | |
| .theme-dark .explanation-box { | |
| background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%) !important; | |
| border: 1px solid #334155 !important; | |
| } | |
| .theme-dark .explanation-box, | |
| .theme-dark .explanation-box * { | |
| color: #f1f5f9 !important; | |
| } | |
| .theme-dark .explanation-box strong, | |
| .theme-dark .explanation-box b { | |
| color: #fbbf24 !important; | |
| font-weight: 600; | |
| } | |
| /* ==================== BUTTONS ==================== */ | |
| button { | |
| transition: all 0.3s ease !important; | |
| font-weight: 500 !important; | |
| } | |
| button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; | |
| } | |
| button:active { | |
| transform: translateY(0); | |
| } | |
| .primary-btn { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; | |
| border: none !important; | |
| color: white !important; | |
| font-size: 1.1em !important; | |
| padding: 12px 30px !important; | |
| border-radius: 10px !important; | |
| } | |
| /* ==================== LOADING ANIMATION ==================== */ | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .loading { | |
| animation: pulse 1.5s ease-in-out infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| .spinner { | |
| display: inline-block; | |
| animation: spin 1s linear infinite; | |
| } | |
| /* ==================== FOOTER ==================== */ | |
| .footer { | |
| text-align: center; | |
| padding: 15px 15px; | |
| margin-top: 20px; | |
| border-top: 1px solid rgba(0,0,0,0.1); | |
| font-size: 0.85em; | |
| opacity: 0.8; | |
| } | |
| .theme-dark .footer { | |
| border-top-color: rgba(255,255,255,0.1); | |
| } | |
| /* ==================== CHARACTER COUNTER ==================== */ | |
| .char-counter { | |
| text-align: right; | |
| font-size: 0.85em; | |
| margin-top: 5px; | |
| font-weight: 500; | |
| } | |
| /* ==================== RESPONSIVE ==================== */ | |
| @media (max-width: 768px) { | |
| .hero-title { | |
| font-size: 1.8em; | |
| } | |
| .hero-subtitle { | |
| font-size: 1em; | |
| } | |
| .stats-container { | |
| gap: 20px; | |
| } | |
| .stat-number { | |
| font-size: 1.5em; | |
| } | |
| } | |
| /* ==================== FEEDBACK WIDGET ==================== */ | |
| .feedback-btn { | |
| opacity: 0.6; | |
| transition: all 0.2s ease; | |
| } | |
| .feedback-btn:hover { | |
| opacity: 1; | |
| transform: scale(1.1); | |
| } | |
| /* ==================== ACCESSIBILITY ==================== */ | |
| *:focus-visible { | |
| outline: 2px solid #667eea; | |
| outline-offset: 2px; | |
| } | |
| /* ==================== SMOOTH SCROLLING ==================== */ | |
| html { | |
| scroll-behavior: smooth; | |
| } | |
| """ | |
| # Enhanced JavaScript for theme detection and keyboard shortcuts | |
| custom_js = """ | |
| function() { | |
| // ==================== THEME DETECTION ==================== | |
| function updateTheme() { | |
| const gradioApp = document.querySelector('gradio-app'); | |
| const body = document.body; | |
| const html = document.documentElement; | |
| const isDark = body.classList.contains('dark') || | |
| html.classList.contains('dark') || | |
| gradioApp?.classList.contains('dark') || | |
| window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| if (isDark) { | |
| body.classList.add('theme-dark'); | |
| body.classList.remove('theme-light'); | |
| } else { | |
| body.classList.add('theme-light'); | |
| body.classList.remove('theme-dark'); | |
| } | |
| } | |
| // Update theme immediately | |
| updateTheme(); | |
| // Watch for theme changes | |
| const observer = new MutationObserver(updateTheme); | |
| observer.observe(document.documentElement, { | |
| attributes: true, | |
| attributeFilter: ['class', 'data-theme'] | |
| }); | |
| observer.observe(document.body, { | |
| attributes: true, | |
| attributeFilter: ['class', 'data-theme'] | |
| }); | |
| // Watch gradio-app | |
| const checkGradioApp = setInterval(() => { | |
| const app = document.querySelector('gradio-app'); | |
| if (app) { | |
| observer.observe(app, { | |
| attributes: true, | |
| attributeFilter: ['class', 'data-theme'] | |
| }); | |
| clearInterval(checkGradioApp); | |
| } | |
| }, 100); | |
| // Listen for system theme changes | |
| window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme); | |
| // ==================== KEYBOARD SHORTCUTS ==================== | |
| document.addEventListener('keydown', function(e) { | |
| // Ctrl/Cmd + Enter to submit | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| const submitBtn = document.querySelector('button.primary-btn'); | |
| if (submitBtn) submitBtn.click(); | |
| } | |
| // Escape to clear | |
| if (e.key === 'Escape') { | |
| const clearBtn = document.querySelector('[value="Clear"]'); | |
| if (clearBtn) clearBtn.click(); | |
| } | |
| }); | |
| // ==================== SMOOTH ANIMATIONS ==================== | |
| // Add entrance animations | |
| setTimeout(() => { | |
| document.querySelectorAll('.gradio-container > *').forEach((el, idx) => { | |
| el.style.opacity = '0'; | |
| el.style.transform = 'translateY(20px)'; | |
| setTimeout(() => { | |
| el.style.transition = 'all 0.5s ease'; | |
| el.style.opacity = '1'; | |
| el.style.transform = 'translateY(0)'; | |
| }, idx * 50); | |
| }); | |
| }, 100); | |
| console.log('π§ AI Concept Explainer v""" + VERSION + """loaded successfully!'); | |
| console.log('π‘ Keyboard shortcuts: Ctrl/Cmd+Enter to submit, Escape to clear'); | |
| } | |
| """ | |
| # ==================== BUILD GRADIO APP ==================== | |
| with gr.Blocks( | |
| theme=gr.themes.Soft( | |
| primary_hue="indigo", | |
| secondary_hue="purple", | |
| neutral_hue="slate", | |
| font=[gr.themes.GoogleFont("Inter"), "Arial", "sans-serif"] | |
| ), | |
| title=f"{APP_NAME} v{VERSION}", | |
| css=custom_css, | |
| js=custom_js | |
| ) as app: | |
| # Hero Header | |
| gr.HTML(""" | |
| <div class="hero-section"> | |
| <div class="hero-title">π§ AI Concept Explainer</div> | |
| <div class="hero-subtitle">Get clear, personalized explanations of any concept at your preferred complexity level and language</div> | |
| </div> | |
| """) | |
| # Quick Examples | |
| gr.Markdown("### π‘ Quick Examples") | |
| with gr.Row(): | |
| example_btn1 = gr.Button(EXAMPLE_QUESTIONS[0], size="sm", variant="secondary") | |
| example_btn2 = gr.Button(EXAMPLE_QUESTIONS[1], size="sm", variant="secondary") | |
| example_btn3 = gr.Button(EXAMPLE_QUESTIONS[2], size="sm", variant="secondary") | |
| # Input Section | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| question = gr.Textbox( | |
| label="π What would you like to understand?", | |
| placeholder="Enter your question or select an example above...", | |
| lines=2, | |
| max_lines=4 | |
| ) | |
| char_count = gr.HTML("<div class='char-counter'>0/500</div>") | |
| with gr.Column(scale=1): | |
| level = gr.Slider( | |
| 1, 5, | |
| value=3, | |
| step=1, | |
| label="π Complexity Level", | |
| info="1 = Simple, 5 = Expert" | |
| ) | |
| language = gr.Dropdown( | |
| LANGUAGES, | |
| value="English", | |
| label="π Language", | |
| info="Choose your preferred language" | |
| ) | |
| # Action Buttons | |
| with gr.Row(): | |
| explain_btn = gr.Button( | |
| "π Explain Concept", | |
| variant="primary", | |
| size="lg", | |
| elem_classes=["primary-btn"] | |
| ) | |
| clear_btn = gr.Button( | |
| "π Clear", | |
| variant="secondary", | |
| size="lg" | |
| ) | |
| # Output Section | |
| gr.Markdown("### π Explanation") | |
| reading_time = gr.Markdown("") | |
| output = gr.Markdown( | |
| value="*Your explanation will appear here...*", | |
| elem_classes=["explanation-box"] | |
| ) | |
| # Action buttons for output | |
| with gr.Row(): | |
| copy_btn = gr.Button("π Copy to Clipboard", size="sm") | |
| feedback_positive = gr.Button("π Helpful", size="sm", elem_classes=["feedback-btn"]) | |
| feedback_negative = gr.Button("π Not Helpful", size="sm", elem_classes=["feedback-btn"]) | |
| feedback_msg = gr.Markdown("") | |
| # Footer | |
| gr.HTML(f""" | |
| <div class="footer"> | |
| <strong>{APP_NAME}</strong> v{VERSION} β’ Powered by OpenAI GPT-4 β’ Built with Gradio β’ Made with β€οΈ for learning | |
| </div> | |
| """) | |
| # ==================== EVENT HANDLERS ==================== | |
| # Character counter | |
| question.change( | |
| fn=update_char_count, | |
| inputs=[question], | |
| outputs=[char_count] | |
| ) | |
| # Example buttons | |
| example_btn1.click(lambda: EXAMPLE_QUESTIONS[0], outputs=question) | |
| example_btn2.click(lambda: EXAMPLE_QUESTIONS[1], outputs=question) | |
| example_btn3.click(lambda: EXAMPLE_QUESTIONS[2], outputs=question) | |
| # Main explain button | |
| explain_btn.click( | |
| fn=explain_concept, | |
| inputs=[question, level, language], | |
| outputs=[output, reading_time] | |
| ) | |
| # Clear button | |
| clear_btn.click( | |
| fn=clear_inputs, | |
| outputs=[question, level, language] | |
| ) | |
| # Copy button (client-side) | |
| copy_btn.click( | |
| None, | |
| inputs=[output], | |
| outputs=[feedback_msg], | |
| js=""" | |
| (output) => { | |
| const text = output; | |
| navigator.clipboard.writeText(text).then(() => { | |
| return 'β Copied to clipboard!'; | |
| }).catch(() => { | |
| return 'β Failed to copy. Please try manually selecting the text.'; | |
| }); | |
| } | |
| """ | |
| ) | |
| # Feedback buttons | |
| def record_feedback(feedback_type: str) -> str: | |
| logger.info(f"User feedback: {feedback_type}") | |
| return f"β Thanks for your feedback!" | |
| feedback_positive.click( | |
| fn=lambda: record_feedback("positive"), | |
| outputs=[feedback_msg] | |
| ) | |
| feedback_negative.click( | |
| fn=lambda: record_feedback("negative"), | |
| outputs=[feedback_msg] | |
| ) | |
| # ==================== APP LAUNCH ==================== | |
| def signal_handler(signum, frame): | |
| """Handle graceful shutdown for local development.""" | |
| logger.info(f"Received signal {signum}, shutting down gracefully...") | |
| exit(0) | |
| if __name__ == "__main__": | |
| is_space = os.getenv('SPACE_ID') is not None | |
| if not is_space: | |
| signal.signal(signal.SIGINT, signal_handler) | |
| logger.info(f"π Starting {APP_NAME} v{VERSION}...") | |
| logger.info(f"API Key configured: {'β Yes' if openai_api_key else 'β No'}") | |
| if is_space: | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| quiet=False | |
| ) | |
| else: | |
| app.launch( | |
| share=False, | |
| server_name="127.0.0.1", | |
| server_port=7860, | |
| inbrowser=True, | |
| show_error=True, | |
| quiet=False | |
| ) | |