"""
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.
Technical details
{error_type}: {str(e)[:200]} """
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.
Technical details
{str(e)} """
else:
error_display = f"""❌ **Unexpected Error**: Something went wrong.
Please try again. If the problem persists, contact support.
Technical details
[{error_type}] {str(e)} """
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'{count}/{MAX_QUESTION_LENGTH}{status}'
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("""
🧠 AI Concept Explainer
Get clear, personalized explanations of any concept at your preferred complexity level and language
""")
# 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("0/500
")
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"""
""")
# ==================== 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
)