Spaces:
Running
Running
""" | |
π¨ Gradio Application for Course Creator AI | |
Main Gradio interface for course generation. | |
""" | |
import gradio as gr | |
from typing import Dict, Any, Optional, Tuple | |
import asyncio | |
import json | |
import markdown | |
import re | |
from ..agents.simple_course_agent import SimpleCourseAgent | |
from ..types import DifficultyLevel, GenerationOptions, LearningStyle | |
from .components import CoursePreview | |
from .styling import get_custom_css | |
def format_lessons(lessons: list) -> str: | |
"""Format lessons from JSON data into HTML with dark theme and markdown support""" | |
if not lessons: | |
return "<div class='info'>π No lessons generated yet.</div>" | |
# Add CSS for lesson styling | |
css = """ | |
<style> | |
/* Force dark theme for all lesson elements */ | |
.lessons-container * { | |
background: transparent !important; | |
color: inherit !important; | |
} | |
.lessons-container { | |
padding: 1rem !important; | |
background: #1a1a2e !important; | |
border-radius: 12px !important; | |
margin: 1rem 0 !important; | |
max-height: none !important; | |
overflow: visible !important; | |
} | |
.lesson-card { | |
background: #2d2d54 !important; | |
border: 1px solid #4a4a7a !important; | |
border-radius: 12px !important; | |
padding: 2rem !important; | |
margin: 1.5rem 0 !important; | |
box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important; | |
color: #e0e7ff !important; | |
} | |
.lesson-card h3 { | |
color: #667eea !important; | |
margin-bottom: 1rem !important; | |
font-size: 1.5rem !important; | |
border-bottom: 2px solid #667eea !important; | |
padding-bottom: 0.5rem !important; | |
background: transparent !important; | |
} | |
.lesson-card h4 { | |
color: #8b9dc3 !important; | |
margin: 1.5rem 0 0.75rem 0 !important; | |
font-size: 1.2rem !important; | |
background: transparent !important; | |
} | |
.lesson-card p { | |
color: #b8c5d6 !important; | |
line-height: 1.6 !important; | |
margin: 0.75rem 0 !important; | |
font-size: 1rem !important; | |
background: transparent !important; | |
} | |
.lesson-card ul { | |
color: #e0e7ff !important; | |
margin: 0.75rem 0 !important; | |
padding-left: 1.5rem !important; | |
background: transparent !important; | |
} | |
.lesson-card li { | |
color: #e0e7ff !important; | |
margin: 0.5rem 0 !important; | |
line-height: 1.5 !important; | |
background: transparent !important; | |
} | |
.lesson-content { | |
background: #3a3a6b !important; | |
border-radius: 8px !important; | |
padding: 1.5rem !important; | |
margin: 1rem 0 !important; | |
border-left: 4px solid #667eea !important; | |
} | |
.lesson-content h1, .lesson-content h2, .lesson-content h3, | |
.lesson-content h4, .lesson-content h5, .lesson-content h6 { | |
color: #667eea !important; | |
margin: 1rem 0 0.5rem 0 !important; | |
background: transparent !important; | |
} | |
.lesson-content p { | |
color: #e0e7ff !important; | |
margin: 0.75rem 0 !important; | |
line-height: 1.6 !important; | |
background: transparent !important; | |
} | |
.lesson-content ul, .lesson-content ol { | |
color: #e0e7ff !important; | |
margin: 0.75rem 0 !important; | |
padding-left: 1.5rem !important; | |
background: transparent !important; | |
} | |
.lesson-content li { | |
color: #e0e7ff !important; | |
margin: 0.5rem 0 !important; | |
background: transparent !important; | |
} | |
.lesson-content strong { | |
color: #8b9dc3 !important; | |
} | |
.lesson-content em { | |
color: #b8c5d6 !important; | |
} | |
.lesson-content code { | |
background: #4a4a7a !important; | |
color: #e0e7ff !important; | |
padding: 0.2rem 0.4rem; | |
border-radius: 4px; | |
font-family: monospace; | |
} | |
.lesson-content pre { | |
background: #4a4a7a !important; | |
color: #e0e7ff !important; | |
padding: 1rem; | |
border-radius: 8px; | |
overflow-x: auto; | |
margin: 1rem 0; | |
} | |
.lesson-card ul { | |
color: #e0e7ff !important; | |
margin: 0.75rem 0; | |
padding-left: 1.5rem; | |
} | |
.lesson-card li { | |
color: #e0e7ff !important; | |
margin: 0.5rem 0; | |
line-height: 1.5; | |
} | |
.lesson-image { | |
margin: 1.5rem 0; | |
text-align: center; | |
} | |
.image-placeholder { | |
background: #4a4a7a; | |
border: 2px dashed #667eea; | |
border-radius: 8px; | |
padding: 2rem; | |
text-align: center; | |
color: #b8c5d6; | |
} | |
.image-icon { | |
font-size: 3rem; | |
margin-bottom: 1rem; | |
} | |
.image-description { | |
font-size: 1.1rem; | |
margin-bottom: 0.5rem; | |
color: #e0e7ff; | |
} | |
.image-note { | |
font-size: 0.9rem; | |
font-style: italic; | |
opacity: 0.7; | |
} | |
.duration-info { | |
background: #4a4a7a !important; | |
color: #e0e7ff !important; | |
padding: 0.5rem 1rem !important; | |
border-radius: 20px !important; | |
display: inline-block !important; | |
margin-bottom: 1rem !important; | |
font-size: 0.9rem !important; | |
} | |
/* Ultimate override for any stubborn white backgrounds */ | |
.lessons-container .lesson-card, | |
.lessons-container .lesson-card *, | |
.lessons-container .lesson-content, | |
.lessons-container .lesson-content * { | |
background-color: transparent !important; | |
} | |
.lessons-container .lesson-card { | |
background: #2d2d54 !important; | |
} | |
.lessons-container .lesson-content { | |
background: #3a3a6b !important; | |
} | |
</style> | |
""" | |
html = css + "<div class='lessons-container'>" | |
for i, lesson in enumerate(lessons, 1): | |
title = lesson.get("title", f"Lesson {i}") | |
content = lesson.get("content", "") | |
duration = lesson.get("duration", "") | |
objectives = lesson.get("objectives", []) | |
key_takeaways = lesson.get("key_takeaways", []) | |
image_description = lesson.get("image_description", "") | |
# Convert markdown content to HTML | |
if content: | |
try: | |
# Create markdown instance with extensions | |
import markdown | |
md = markdown.Markdown(extensions=['extra', 'codehilite']) | |
content_html = md.convert(content) | |
except ImportError: | |
# Fallback if markdown is not available | |
content_html = content.replace('\n\n', '</p><p>').replace('\n', '<br>') | |
if content_html and not content_html.startswith('<p>'): | |
content_html = f'<p>{content_html}</p>' | |
else: | |
content_html = "<p>No content available.</p>" | |
# Generate image placeholder or actual image | |
image_html = "" | |
if image_description: | |
# Check if we have actual image data | |
images = lesson.get("images", []) | |
if images and len(images) > 0: | |
# Display actual generated images | |
image_html = "<div class='lesson-images'>" | |
for img in images: | |
if isinstance(img, dict) and img.get("url"): | |
img_url = img.get("url", "") | |
img_caption = img.get("description", image_description) | |
image_html += f""" | |
<div class='lesson-image'> | |
<img src='{img_url}' alt='{img_caption}' loading='lazy' style='max-width: 100%; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.3); border: 2px solid #4a4a7a;'> | |
<p class='image-caption'>{img_caption}</p> | |
</div> | |
""" | |
image_html += "</div>" | |
else: | |
# Fallback to placeholder | |
image_html = f""" | |
<div class='lesson-image'> | |
<div class='image-placeholder'> | |
<div class='image-icon'>πΌοΈ</div> | |
<div class='image-description'>{image_description}</div> | |
<div class='image-note'>(Image generation in progress...)</div> | |
</div> | |
</div> | |
""" | |
html += f""" | |
<div class='lesson-card'> | |
<h3>π {title}</h3> | |
{f"<div class='duration-info'>β±οΈ Duration: {duration} minutes</div>" if duration else ""} | |
{f"<h4>π― Learning Objectives:</h4><ul>{''.join([f'<li>{obj}</li>' for obj in objectives])}</ul>" if objectives else ""} | |
{image_html} | |
<div class='lesson-content'> | |
{content_html} | |
</div> | |
{f"<h4>π‘ Key Takeaways:</h4><ul>{''.join([f'<li>{takeaway}</li>' for takeaway in key_takeaways])}</ul>" if key_takeaways else ""} | |
</div> | |
""" | |
html += "</div>" | |
return html | |
def format_flashcards(flashcards: list) -> str: | |
"""Format flashcards from JSON data into interactive HTML with CSS-only flip""" | |
if not flashcards: | |
return "<div class='info'>π No flashcards generated yet.</div>" | |
# Add the CSS for flashcard flip functionality | |
css = """ | |
<style> | |
.flashcards-container { | |
padding: 1rem; | |
background: #1a1a2e; | |
border-radius: 12px; | |
margin: 1rem 0; | |
max-height: none !important; | |
overflow: visible !important; | |
} | |
.flashcard-wrapper { | |
perspective: 1000px; | |
margin: 1rem 0; | |
height: 200px; | |
} | |
.flip-checkbox { | |
display: none; | |
} | |
.flashcard { | |
position: relative; | |
width: 100%; | |
height: 100%; | |
cursor: pointer; | |
transform-style: preserve-3d; | |
transition: transform 0.6s; | |
display: block; | |
} | |
.flip-checkbox:checked + .flashcard { | |
transform: rotateY(180deg); | |
} | |
.flashcard-inner { | |
position: relative; | |
width: 100%; | |
height: 100%; | |
transform-style: preserve-3d; | |
} | |
.flashcard-front, .flashcard-back { | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
backface-visibility: hidden; | |
border-radius: 12px; | |
padding: 1.5rem; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
text-align: center; | |
box-shadow: 0 4px 8px rgba(0,0,0,0.3); | |
} | |
.flashcard-front { | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
color: white; | |
} | |
.flashcard-back { | |
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); | |
color: white; | |
transform: rotateY(180deg); | |
} | |
.flashcard-category { | |
position: absolute; | |
top: 10px; | |
right: 15px; | |
background: rgba(255,255,255,0.2); | |
padding: 0.25rem 0.5rem; | |
border-radius: 12px; | |
font-size: 0.8rem; | |
font-weight: bold; | |
} | |
.flashcard-content { | |
font-size: 1.2rem; | |
font-weight: 500; | |
line-height: 1.4; | |
margin: 1rem 0; | |
color: white; | |
} | |
.flashcard-hint { | |
position: absolute; | |
bottom: 10px; | |
left: 50%; | |
transform: translateX(-50%); | |
font-size: 0.8rem; | |
opacity: 0.8; | |
font-style: italic; | |
} | |
.flashcard:hover { | |
box-shadow: 0 6px 12px rgba(0,0,0,0.4); | |
} | |
</style> | |
""" | |
html = css + "<div class='flashcards-container'>" | |
html += "<p style='color: #e0e7ff; text-align: center; margin-bottom: 1rem;'><strong>π Click on any flashcard to flip it and see the answer!</strong></p>" | |
for i, card in enumerate(flashcards): | |
question = card.get("question", "") | |
answer = card.get("answer", "") | |
category = card.get("category", "General") | |
# Use CSS-only flip with checkbox hack | |
html += f""" | |
<div class='flashcard-wrapper'> | |
<input type='checkbox' id='flip-{i}' class='flip-checkbox'> | |
<label for='flip-{i}' class='flashcard'> | |
<div class='flashcard-inner'> | |
<div class='flashcard-front'> | |
<div class='flashcard-category'>{category}</div> | |
<div class='flashcard-content'>{question}</div> | |
<div class='flashcard-hint'>Click to flip</div> | |
</div> | |
<div class='flashcard-back'> | |
<div class='flashcard-category'>{category}</div> | |
<div class='flashcard-content'>{answer}</div> | |
<div class='flashcard-hint'>Click to flip back</div> | |
</div> | |
</div> | |
</label> | |
</div> | |
""" | |
html += "</div>" | |
return html | |
def format_quiz(quiz: dict) -> str: | |
"""Format quiz from JSON data into interactive HTML with working JavaScript.""" | |
if not quiz or not quiz.get("questions"): | |
return "<div class='info'>π No quiz generated yet.</div>" | |
title = quiz.get("title", "Course Quiz") | |
instructions = quiz.get("instructions", "Choose the best answer for each question.") | |
questions = quiz.get("questions", []) | |
if not questions: | |
return "<div class='info'>π No quiz questions available.</div>" | |
# Generate unique quiz ID | |
quiz_id = f"quiz_{abs(hash(str(questions)))%10000}" | |
# CSS and JavaScript for quiz functionality | |
quiz_html = f""" | |
<style> | |
.quiz-container {{ | |
background: #1a1a2e; | |
border-radius: 12px; | |
padding: 2rem; | |
color: #e0e7ff; | |
max-height: none !important; | |
overflow: visible !important; | |
}} | |
.quiz-container h3 {{ | |
color: #667eea; | |
text-align: center; | |
margin-bottom: 1rem; | |
}} | |
.quiz-question {{ | |
background: #2d2d54; | |
border-radius: 8px; | |
padding: 1.5rem; | |
margin: 1.5rem 0; | |
border-left: 4px solid #667eea; | |
}} | |
.quiz-question h4 {{ | |
color: #e0e7ff; | |
margin-bottom: 1rem; | |
font-size: 1.1rem; | |
}} | |
.quiz-options {{ | |
margin: 1rem 0; | |
}} | |
.quiz-option-label {{ | |
display: flex; | |
align-items: center; | |
padding: 0.75rem 1rem; | |
background: #3a3a6b; | |
border: 2px solid #4a4a7a; | |
border-radius: 8px; | |
margin: 0.5rem 0; | |
cursor: pointer; | |
color: #e0e7ff; | |
transition: all 0.2s; | |
}} | |
.quiz-option-label:hover {{ | |
background: #4a4a7a; | |
border-color: #667eea; | |
}} | |
.quiz-radio {{ | |
display: none; | |
}} | |
.quiz-radio:checked + .quiz-option-label {{ | |
background: #667eea; | |
color: white; | |
border-color: #667eea; | |
}} | |
.radio-custom {{ | |
width: 20px; | |
height: 20px; | |
border: 2px solid #667eea; | |
border-radius: 50%; | |
margin-right: 0.75rem; | |
position: relative; | |
}} | |
.quiz-radio:checked + .quiz-option-label .radio-custom::after {{ | |
content: ''; | |
width: 10px; | |
height: 10px; | |
border-radius: 50%; | |
background: white; | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
}} | |
.quiz-feedback {{ | |
margin-top: 1rem; | |
padding: 1rem; | |
border-radius: 6px; | |
font-weight: 500; | |
display: none; | |
}} | |
.feedback-correct {{ | |
background: #d4edda; | |
color: #155724; | |
border: 1px solid #c3e6cb; | |
}} | |
.feedback-incorrect {{ | |
background: #f8d7da; | |
color: #721c24; | |
border: 1px solid #f5c6cb; | |
}} | |
.feedback-unanswered {{ | |
background: #fff3cd; | |
color: #856404; | |
border: 1px solid #ffeaa7; | |
}} | |
.quiz-results {{ | |
margin-top: 2rem; | |
padding: 1.5rem; | |
background: linear-gradient(135deg, #667eea, #764ba2); | |
color: white; | |
border-radius: 8px; | |
text-align: center; | |
font-size: 1.1rem; | |
display: none; | |
}} | |
.quiz-score {{ | |
font-size: 1.5rem; | |
font-weight: bold; | |
margin-bottom: 0.5rem; | |
}} | |
</style> | |
<div class="quiz-container" id="{quiz_id}"> | |
<h3>π {title}</h3> | |
<p style="text-align:center; color:#b8c5d6; margin-bottom: 2rem;"><em>{instructions}</em></p> | |
<form id="quiz-form-{quiz_id}"> | |
""" | |
# Display each question | |
for idx, q in enumerate(questions): | |
question_text = q.get("question", "") | |
options = q.get("options", []) | |
correct_answer = q.get("correct_answer", "A") | |
explanation = q.get("explanation", "") | |
quiz_html += f""" | |
<div class="quiz-question" data-correct="{correct_answer}" data-explanation="{explanation}"> | |
<h4>Q{idx+1}: {question_text}</h4> | |
<div class="quiz-options"> | |
""" | |
# Display options | |
for j, option in enumerate(options): | |
option_letter = option[0] if option and len(option) > 0 else chr(65 + j) | |
option_text = option[3:] if option.startswith(f"{option_letter}. ") else option | |
quiz_html += f""" | |
<div> | |
<input type="radio" id="q{idx}_o{j}_{quiz_id}" name="q{idx}" value="{option_letter}" class="quiz-radio"> | |
<label for="q{idx}_o{j}_{quiz_id}" class="quiz-option-label"> | |
<span class="radio-custom"></span> | |
<strong>{option_letter}.</strong> {option_text} | |
</label> | |
</div> | |
""" | |
quiz_html += f""" | |
</div> | |
<div class="quiz-feedback" id="feedback-{idx}-{quiz_id}"></div> | |
</div> | |
""" | |
# Close form and add results container | |
quiz_html += f""" | |
</form> | |
</div> | |
""" | |
return quiz_html | |
def create_coursecrafter_interface() -> gr.Blocks: | |
"""Create the main Course Creator Gradio interface""" | |
with gr.Blocks( | |
title="Course Creator AI - Intelligent Course Generator", | |
css=get_custom_css(), | |
theme=gr.themes.Soft() | |
) as interface: | |
# Header | |
gr.HTML(""" | |
<div class="header-container"> | |
<h1>π Course Creator AI</h1> | |
<p>Generate comprehensive mini-courses with AI-powered content, flashcards, and quizzes</p> | |
</div> | |
""") | |
# LLM Provider Configuration | |
with gr.Row(): | |
with gr.Column(): | |
gr.HTML("<h3>π€ LLM Provider Configuration</h3>") | |
with gr.Row(): | |
llm_provider = gr.Dropdown( | |
label="LLM Provider", | |
choices=["openai", "anthropic", "google", "openai_compatible"], | |
value="google", | |
info="Choose your preferred LLM provider" | |
) | |
api_key_input = gr.Textbox( | |
label="API Key", | |
placeholder="Enter your API key here...", | |
type="password", | |
info="Your API key for the selected provider (optional for OpenAI-compatible)" | |
) | |
# OpenAI-Compatible endpoint configuration (initially hidden) | |
with gr.Row(visible=False) as openai_compatible_row: | |
endpoint_url_input = gr.Textbox( | |
label="Endpoint URL", | |
placeholder="https://your-endpoint.com/v1", | |
info="Base URL for OpenAI-compatible API" | |
) | |
model_name_input = gr.Textbox( | |
label="Model Name", | |
placeholder="your-model-name", | |
info="Model name to use with the endpoint" | |
) | |
# Main interface | |
with gr.Row(): | |
with gr.Column(scale=1): | |
# Course generation form | |
topic_input = gr.Textbox( | |
label="Course Topic", | |
placeholder="e.g., Introduction to Python Programming", | |
lines=1 | |
) | |
difficulty_input = gr.Dropdown( | |
label="Difficulty Level", | |
choices=["beginner", "intermediate", "advanced"], | |
value="beginner" | |
) | |
lesson_count = gr.Slider( | |
label="Number of Lessons", | |
minimum=1, | |
maximum=10, | |
value=5, | |
step=1 | |
) | |
generate_btn = gr.Button( | |
"π Generate Course", | |
variant="primary", | |
size="lg" | |
) | |
# Chat interface for course refinement | |
gr.HTML("<hr><h3>π¬ Course Assistant</h3>") | |
# Chat window with proper styling | |
with gr.Column(): | |
chat_display = gr.HTML( | |
value=""" | |
<div class='chat-window'> | |
<div class='chat-messages' id='chat-messages'> | |
<div class='chat-message assistant-message'> | |
<div class='message-avatar'>π€</div> | |
<div class='message-content'> | |
<div class='message-text'>Hi! I'm your Course Assistant. Generate a course first, then ask me questions about the lessons, concepts, or content!</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
""", | |
elem_id="chat-display" | |
) | |
with gr.Row(): | |
chat_input = gr.Textbox( | |
placeholder="Ask me to modify the course...", | |
lines=1, | |
scale=4, | |
container=False | |
) | |
chat_btn = gr.Button("Send", variant="secondary", scale=1) | |
with gr.Column(scale=2): | |
# Course preview tabs with enhanced components | |
course_preview = CoursePreview() | |
with gr.Tabs(): | |
with gr.Tab("π Lessons"): | |
lessons_output = gr.HTML( | |
value=""" | |
<div class='lessons-container' style='padding: 2rem; text-align: center; background: #1a1a2e; border-radius: 12px; color: #e0e7ff;'> | |
<h3 style='color: #667eea; margin-bottom: 1rem;'>π Ready to Generate Your Course!</h3> | |
<p style='color: #b8c5d6; font-size: 1.1rem; margin-bottom: 1.5rem;'>Enter a topic and click "Generate Course" to create comprehensive lessons with AI-powered content.</p> | |
<div style='background: #2d2d54; padding: 1.5rem; border-radius: 8px; border-left: 4px solid #667eea;'> | |
<p style='color: #e0e7ff; margin: 0;'>π‘ <strong>Tip:</strong> Try topics like "Introduction to Python Programming", "Digital Marketing Basics", or "Climate Change Science"</p> | |
</div> | |
</div> | |
""" | |
) | |
with gr.Tab("π Flashcards"): | |
flashcards_output = gr.HTML( | |
value=""" | |
<div class='flashcards-container' style='padding: 2rem; text-align: center; background: #1a1a2e; border-radius: 12px; color: #e0e7ff;'> | |
<h3 style='color: #667eea; margin-bottom: 1rem;'>π Interactive Flashcards</h3> | |
<p style='color: #b8c5d6;'>Flashcards will appear here after course generation. They'll help reinforce key concepts with spaced repetition learning!</p> | |
</div> | |
""" | |
) | |
with gr.Tab("π Quizzes"): | |
# Quiz functionality with HTML content and state management | |
quiz_state = gr.State({}) # Store quiz data | |
quizzes_output = gr.HTML( | |
value=""" | |
<div class='quiz-container' style='padding: 2rem; text-align: center; background: #1a1a2e; border-radius: 12px; color: #e0e7ff;'> | |
<h3 style='color: #667eea; margin-bottom: 1rem;'>π Knowledge Assessment</h3> | |
<p style='color: #b8c5d6;'>Interactive quizzes will appear here to test your understanding of the course material!</p> | |
</div> | |
""" | |
) | |
quiz_results = gr.HTML(visible=False) | |
quiz_submit_btn = gr.Button("Submit Quiz", variant="primary", visible=False) | |
with gr.Tab("π¨ Images"): | |
images_section = course_preview._create_images_section() | |
image_gallery = images_section["image_gallery"] | |
image_details = images_section["image_details"] | |
# Store generated course content for chat context | |
course_context = {"content": "", "topic": "", "agent": None} | |
# Provider change handler to show/hide OpenAI-compatible fields | |
def on_provider_change(provider): | |
if provider == "openai_compatible": | |
return gr.update(visible=True) | |
else: | |
return gr.update(visible=False) | |
# Event handlers | |
async def generate_course_wrapper(topic: str, difficulty: str, lessons: int, provider: str, api_key: str, endpoint_url: str, model_name: str, progress=gr.Progress()): | |
"""Wrapper for course generation with progress tracking""" | |
if not topic.strip(): | |
return ( | |
"<div class='error'>β Please enter a topic for your course.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
if not api_key.strip() and provider != "openai_compatible": | |
return ( | |
"<div class='error'>β Please enter your API key for the selected LLM provider.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
if provider == "openai_compatible" and not endpoint_url.strip(): | |
return ( | |
"<div class='error'>β Please enter the endpoint URL for OpenAI-compatible provider.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
if provider == "openai_compatible" and not model_name.strip(): | |
return ( | |
"<div class='error'>β Please enter the model name for OpenAI-compatible provider.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
try: | |
# Initialize progress | |
progress(0, desc="π Initializing Course Generator...") | |
# Set the API key and configuration for the selected provider | |
import os | |
if provider == "openai": | |
os.environ["OPENAI_API_KEY"] = api_key | |
elif provider == "anthropic": | |
os.environ["ANTHROPIC_API_KEY"] = api_key | |
elif provider == "google": | |
os.environ["GOOGLE_API_KEY"] = api_key | |
elif provider == "openai_compatible": | |
if api_key.strip(): | |
os.environ["OPENAI_COMPATIBLE_API_KEY"] = api_key | |
os.environ["OPENAI_COMPATIBLE_BASE_URL"] = endpoint_url | |
os.environ["OPENAI_COMPATIBLE_MODEL"] = model_name | |
# IMPORTANT: Create a fresh agent instance to pick up the new environment variables | |
# This ensures the LlmClient reinitializes with the updated API keys | |
agent = SimpleCourseAgent() | |
# Use the new dynamic configuration method to update provider settings | |
config_kwargs = {} | |
if provider == "openai_compatible": | |
config_kwargs["base_url"] = endpoint_url | |
config_kwargs["model"] = model_name | |
# Update provider configuration dynamically | |
config_success = agent.update_provider_config(provider, api_key, **config_kwargs) | |
if not config_success: | |
return ( | |
f"<div class='error'>β Failed to configure provider '{provider}'. Please check your API key and settings.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
course_context["agent"] = agent | |
course_context["topic"] = topic | |
# Verify the provider is available with the new configuration | |
available_providers = agent.get_available_providers() | |
if provider not in available_providers: | |
return ( | |
f"<div class='error'>β Provider '{provider}' is not available after configuration. Please check your API key and configuration.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
progress(0.1, desc="βοΈ Setting up generation options...") | |
# Create generation options | |
options = GenerationOptions( | |
difficulty=DifficultyLevel(difficulty), | |
lesson_count=lessons, | |
include_images=True, | |
include_flashcards=True, | |
include_quizzes=True | |
) | |
progress(0.15, desc="π Checking available providers...") | |
# Get available providers | |
available_providers = agent.get_available_providers() | |
if not available_providers: | |
return ( | |
"<div class='error'>β No LLM providers available. Please check your API keys.</div>", | |
"", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
progress(0.2, desc="π Starting course generation...") | |
# Use the default provider from config (no need to override) | |
# The agent will automatically use the configured default provider | |
# Start course generation | |
lessons_html = "" | |
flashcards_html = "" | |
quizzes_html = "" | |
# Stream the generation process | |
course_data = None | |
current_progress = 0.2 | |
# Add a simple counter for fallback progress | |
chunk_count = 0 | |
max_expected_chunks = 10 # Rough estimate | |
async for chunk in agent.generate_course(topic, options): | |
chunk_count += 1 | |
print(f"π Progress Debug: Received chunk type='{chunk.type}', content='{chunk.content}'") | |
# Update progress based on chunk content | |
if chunk.type == "progress": | |
# Check if the progress message matches our known steps (handle emojis) | |
step_found = False | |
progress_message = chunk.content.lower() | |
print(f"π Checking progress message: '{progress_message}'") | |
if "research completed" in progress_message: | |
current_progress = 0.3 | |
step_found = True | |
print(f"β Matched: Research completed -> {current_progress}") | |
progress(current_progress, desc="π Research completed, planning course structure...") | |
elif "course structure planned" in progress_message: | |
current_progress = 0.4 | |
step_found = True | |
print(f"β Matched: Course structure planned -> {current_progress}") | |
progress(current_progress, desc="π Course structure planned, generating content...") | |
elif "lessons created" in progress_message: | |
current_progress = 0.6 | |
step_found = True | |
print(f"β Matched: Lessons created -> {current_progress}") | |
progress(current_progress, desc="βοΈ Lessons created, generating flashcards...") | |
elif "flashcards created" in progress_message: | |
current_progress = 0.75 | |
step_found = True | |
print(f"β Matched: Flashcards created -> {current_progress}") | |
progress(current_progress, desc="π Flashcards created, creating quiz...") | |
elif "quiz created" in progress_message: | |
current_progress = 0.8 | |
step_found = True | |
print(f"β Matched: Quiz created -> {current_progress}") | |
progress(current_progress, desc="β Quiz created, generating images...") | |
elif "images generated" in progress_message: | |
current_progress = 0.9 | |
step_found = True | |
print(f"β Matched: Images generated -> {current_progress}") | |
progress(current_progress, desc="π¨ Images generated, finalizing course...") | |
elif "finalizing course" in progress_message: | |
current_progress = 0.95 | |
step_found = True | |
print(f"β Matched: Finalizing course -> {current_progress}") | |
progress(current_progress, desc="π¦ Assembling final course data...") | |
if not step_found: | |
# Fallback: increment progress based on chunk count | |
fallback_progress = min(0.2 + (chunk_count / max_expected_chunks) * 0.6, 0.85) | |
current_progress = max(current_progress, fallback_progress) | |
print(f"β οΈ No match found, using fallback: {fallback_progress}") | |
progress(current_progress, desc=f"οΏ½οΏ½ {chunk.content}") | |
elif chunk.type == "course_complete": | |
current_progress = 0.95 | |
progress(current_progress, desc="π¦ Finalizing course data...") | |
# Parse the complete course data | |
try: | |
course_data = json.loads(chunk.content) | |
except: | |
course_data = None | |
progress(0.97, desc="π¨ Processing course content...") | |
# If we got course data, format it nicely | |
if course_data: | |
course_context["content"] = course_data | |
# Format lessons | |
lessons_html = format_lessons(course_data.get("lessons", [])) | |
# Format flashcards | |
flashcards_html = format_flashcards(course_data.get("flashcards", [])) | |
# Format quiz | |
quiz_data = course_data.get("quiz", {}) | |
quizzes_html = format_quiz(quiz_data) | |
# Show quiz button if quiz exists - be more permissive to ensure it shows | |
quiz_btn_visible = bool(quiz_data and (quiz_data.get("questions") or len(str(quiz_data)) > 50)) | |
print(f"π― Quiz button visibility: {quiz_btn_visible} (quiz_data: {bool(quiz_data)}, questions: {bool(quiz_data.get('questions') if quiz_data else False)})") | |
# Force quiz button to be visible if we have any quiz content | |
if quiz_data and not quiz_btn_visible: | |
print("β οΈ Forcing quiz button to be visible due to quiz data presence") | |
quiz_btn_visible = True | |
progress(0.98, desc="πΌοΈ Processing images for gallery...") | |
# Prepare image gallery data - fix the format for Gradio Gallery | |
images = [] | |
image_details_list = [] | |
# Process images from lessons | |
for lesson in course_data.get("lessons", []): | |
lesson_images = lesson.get("images", []) | |
for i, img in enumerate(lesson_images): | |
try: | |
if isinstance(img, dict): | |
# Handle different image data formats | |
image_url = img.get("url") or img.get("data_url") | |
if image_url: | |
alt_text = img.get("caption", img.get("description", "Educational image")) | |
# Handle base64 data URLs by converting to temp files | |
if image_url.startswith('data:image/'): | |
import base64 | |
import tempfile | |
import os | |
# Extract base64 data | |
header, data = image_url.split(',', 1) | |
image_data = base64.b64decode(data) | |
# Determine file extension from header | |
if 'jpeg' in header or 'jpg' in header: | |
ext = '.jpg' | |
elif 'png' in header: | |
ext = '.png' | |
elif 'gif' in header: | |
ext = '.gif' | |
elif 'webp' in header: | |
ext = '.webp' | |
else: | |
ext = '.jpg' # Default | |
# Create temp file | |
temp_fd, temp_path = tempfile.mkstemp(suffix=ext, prefix=f'course_img_{i}_') | |
try: | |
with os.fdopen(temp_fd, 'wb') as f: | |
f.write(image_data) | |
images.append(temp_path) | |
image_details_list.append({ | |
"url": temp_path, | |
"caption": alt_text, | |
"lesson": lesson.get("title", "Unknown lesson") | |
}) | |
except Exception as e: | |
print(f"β οΈ Failed to save temp image {i}: {e}") | |
os.close(temp_fd) # Close if write failed | |
continue | |
elif image_url.startswith('http'): | |
# Regular URL - Gradio can handle these directly | |
images.append(image_url) | |
image_details_list.append({ | |
"url": image_url, | |
"caption": alt_text, | |
"lesson": lesson.get("title", "Unknown lesson") | |
}) | |
else: | |
# Assume it's a file path | |
if len(image_url) <= 260: # Windows path limit | |
images.append(image_url) | |
image_details_list.append({ | |
"url": image_url, | |
"caption": alt_text, | |
"lesson": lesson.get("title", "Unknown lesson") | |
}) | |
else: | |
print(f"β οΈ Skipping image {i}: path too long ({len(image_url)} chars)") | |
elif isinstance(img, str): | |
# Handle case where image is just a URL string | |
if img.startswith('data:image/'): | |
# Handle base64 data URLs | |
import base64 | |
import tempfile | |
import os | |
try: | |
header, data = img.split(',', 1) | |
image_data = base64.b64decode(data) | |
# Determine file extension from header | |
if 'jpeg' in header or 'jpg' in header: | |
ext = '.jpg' | |
elif 'png' in header: | |
ext = '.png' | |
elif 'gif' in header: | |
ext = '.gif' | |
elif 'webp' in header: | |
ext = '.webp' | |
else: | |
ext = '.jpg' # Default | |
# Create temp file | |
temp_fd, temp_path = tempfile.mkstemp(suffix=ext, prefix=f'course_img_{i}_') | |
try: | |
with os.fdopen(temp_fd, 'wb') as f: | |
f.write(image_data) | |
images.append(temp_path) | |
image_details_list.append({ | |
"url": temp_path, | |
"caption": "Educational image", | |
"lesson": lesson.get("title", "Unknown lesson") | |
}) | |
except Exception as e: | |
print(f"β οΈ Failed to save temp image {i}: {e}") | |
os.close(temp_fd) # Close if write failed | |
continue | |
except Exception as e: | |
print(f"β οΈ Error processing base64 image {i}: {e}") | |
continue | |
else: | |
# Regular URL or file path | |
images.append(img) | |
image_details_list.append({ | |
"url": img, | |
"caption": "Educational image", | |
"lesson": lesson.get("title", "Unknown lesson") | |
}) | |
except Exception as e: | |
print(f"β οΈ Error processing image {i}: {e}") | |
continue | |
# Also check for standalone images in course data | |
standalone_images = course_data.get("images", []) | |
for i, img in enumerate(standalone_images): | |
try: | |
if isinstance(img, dict): | |
image_url = img.get("url") or img.get("data_url") | |
if image_url: | |
alt_text = img.get("caption", img.get("description", "Course image")) | |
# Handle base64 data URLs | |
if image_url.startswith('data:image/'): | |
import base64 | |
import tempfile | |
import os | |
try: | |
header, data = image_url.split(',', 1) | |
image_data = base64.b64decode(data) | |
# Determine file extension from header | |
if 'jpeg' in header or 'jpg' in header: | |
ext = '.jpg' | |
elif 'png' in header: | |
ext = '.png' | |
elif 'gif' in header: | |
ext = '.gif' | |
elif 'webp' in header: | |
ext = '.webp' | |
else: | |
ext = '.jpg' # Default | |
# Create temp file | |
temp_fd, temp_path = tempfile.mkstemp(suffix=ext, prefix=f'standalone_img_{i}_') | |
try: | |
with os.fdopen(temp_fd, 'wb') as f: | |
f.write(image_data) | |
images.append(temp_path) | |
image_details_list.append({ | |
"url": temp_path, | |
"caption": alt_text, | |
"lesson": "Course Overview" | |
}) | |
except Exception as e: | |
print(f"β οΈ Failed to save temp standalone image {i}: {e}") | |
os.close(temp_fd) # Close if write failed | |
continue | |
except Exception as e: | |
print(f"β οΈ Error processing base64 standalone image {i}: {e}") | |
continue | |
else: | |
images.append(image_url) | |
image_details_list.append({ | |
"url": image_url, | |
"caption": alt_text, | |
"lesson": "Course Overview" | |
}) | |
elif isinstance(img, str): | |
if img.startswith('data:image/'): | |
# Handle base64 data URLs | |
import base64 | |
import tempfile | |
import os | |
try: | |
header, data = img.split(',', 1) | |
image_data = base64.b64decode(data) | |
# Determine file extension from header | |
if 'jpeg' in header or 'jpg' in header: | |
ext = '.jpg' | |
elif 'png' in header: | |
ext = '.png' | |
elif 'gif' in header: | |
ext = '.gif' | |
elif 'webp' in header: | |
ext = '.webp' | |
else: | |
ext = '.jpg' # Default | |
# Create temp file | |
temp_fd, temp_path = tempfile.mkstemp(suffix=ext, prefix=f'standalone_img_{i}_') | |
try: | |
with os.fdopen(temp_fd, 'wb') as f: | |
f.write(image_data) | |
images.append(temp_path) | |
image_details_list.append({ | |
"url": temp_path, | |
"caption": "Course image", | |
"lesson": "Course Overview" | |
}) | |
except Exception as e: | |
print(f"β οΈ Failed to save temp standalone image {i}: {e}") | |
os.close(temp_fd) # Close if write failed | |
continue | |
except Exception as e: | |
print(f"β οΈ Error processing base64 standalone image {i}: {e}") | |
continue | |
else: | |
images.append(img) | |
image_details_list.append({ | |
"url": img, | |
"caption": "Course image", | |
"lesson": "Course Overview" | |
}) | |
except Exception as e: | |
print(f"β οΈ Error processing standalone image {i}: {e}") | |
continue | |
print(f"πΈ Prepared {len(images)} images for gallery display") | |
# Create image details HTML for display | |
if image_details_list: | |
image_details_html = "<div class='image-details-container'>" | |
image_details_html += "<h4>πΌοΈ Image Gallery</h4>" | |
image_details_html += f"<p>Total images: {len(image_details_list)}</p>" | |
image_details_html += "<ul>" | |
for i, img_detail in enumerate(image_details_list, 1): | |
image_details_html += f"<li><strong>Image {i}:</strong> {img_detail['caption']} (from {img_detail['lesson']})</li>" | |
image_details_html += "</ul></div>" | |
else: | |
image_details_html = "<div class='image-details'>No images available</div>" | |
progress(1.0, desc="β Course generation complete!") | |
return ( | |
lessons_html, flashcards_html, quizzes_html, | |
gr.update(visible=quiz_btn_visible), images, image_details_html | |
) | |
else: | |
quiz_btn_visible = False | |
progress(1.0, desc="β οΈ Course generation completed with issues") | |
return ( | |
"", "", "", | |
gr.update(visible=quiz_btn_visible), [], "<div class='image-details'>No images available</div>" | |
) | |
except Exception as e: | |
import traceback | |
error_details = traceback.format_exc() | |
print(f"Error in course generation: {error_details}") | |
return ( | |
"", "", "", | |
gr.update(visible=False), [], "<div class='image-details'>Error loading images</div>" | |
) | |
def handle_quiz_submit(): | |
"""Handle quiz submission using client-side processing""" | |
# This function will be replaced by client-side JavaScript | |
return gr.update() | |
async def handle_chat(message: str, current_chat: str): | |
"""Handle chat messages for answering questions about the course content""" | |
if not message.strip(): | |
return current_chat, "" | |
if not course_context["content"] or not course_context["agent"]: | |
assistant_response = "Please generate a course first before asking questions about it." | |
else: | |
try: | |
# Get the agent and course content | |
agent = course_context["agent"] | |
course_data = course_context["content"] | |
topic = course_context["topic"] | |
# Create context from the course content | |
course_context_text = f"Course Topic: {topic}\n\n" | |
# Add lessons content | |
lessons = course_data.get("lessons", []) | |
for i, lesson in enumerate(lessons, 1): | |
course_context_text += f"Lesson {i}: {lesson.get('title', '')}\n" | |
course_context_text += f"Content: {lesson.get('content', '')[:1000]}...\n" | |
if lesson.get('key_takeaways'): | |
course_context_text += f"Key Takeaways: {', '.join(lesson.get('key_takeaways', []))}\n" | |
course_context_text += "\n" | |
# Add flashcards context | |
flashcards = course_data.get("flashcards", []) | |
if flashcards: | |
course_context_text += "Flashcards:\n" | |
for card in flashcards[:5]: # Limit to first 5 | |
course_context_text += f"Q: {card.get('question', '')} A: {card.get('answer', '')}\n" | |
course_context_text += "\n" | |
# Create a focused prompt for answering questions | |
prompt = f"""You are a helpful course assistant. Answer the user's question about the course content below. | |
Course Content: | |
{course_context_text} | |
User Question: {message} | |
Instructions: | |
- Answer based ONLY on the course content provided above | |
- Be helpful, clear, and educational | |
- If the question is about something not covered in the course, say so politely | |
- Keep responses concise but informative | |
- Use a friendly, teaching tone | |
Answer:""" | |
# Use the default provider (same as course generation) | |
provider = agent.default_provider | |
available_providers = agent.get_available_providers() | |
if provider not in available_providers: | |
# Fallback to first available if default isn't available | |
provider = available_providers[0] if available_providers else None | |
if provider: | |
# Use the agent's LLM to get a response | |
from ..agents.simple_course_agent import Message | |
messages = [ | |
Message(role="system", content="You are a helpful course assistant that answers questions about course content."), | |
Message(role="user", content=prompt) | |
] | |
print(f"π€ Chat using LLM provider: {provider}") | |
assistant_response = await agent._get_llm_response(provider, messages) | |
# Clean up the response | |
assistant_response = assistant_response.strip() | |
if assistant_response.startswith("Answer:"): | |
assistant_response = assistant_response[7:].strip() | |
else: | |
assistant_response = "Sorry, no LLM providers are available to answer your question." | |
except Exception as e: | |
print(f"Error in chat: {e}") | |
assistant_response = "Sorry, I encountered an error while trying to answer your question. Please try again." | |
# Extract existing messages from current chat HTML | |
existing_messages = "" | |
if current_chat and "chat-message" in current_chat: | |
# Keep existing messages | |
start = current_chat.find('<div class="chat-messages"') | |
if start != -1: | |
end = current_chat.find('</div>', start) | |
if end != -1: | |
existing_content = current_chat[start:end] | |
# Extract just the message divs | |
import re | |
messages_match = re.findall(r'<div class="chat-message.*?</div>\s*</div>', existing_content, re.DOTALL) | |
existing_messages = ''.join(messages_match) | |
# Create new chat HTML with existing messages plus new ones | |
new_chat = f""" | |
<div class='chat-window'> | |
<div class='chat-messages' id='chat-messages'> | |
{existing_messages} | |
<div class='chat-message user-message'> | |
<div class='message-avatar'>π€</div> | |
<div class='message-content'> | |
<div class='message-text'>{message}</div> | |
</div> | |
</div> | |
<div class='chat-message assistant-message'> | |
<div class='message-avatar'>π€</div> | |
<div class='message-content'> | |
<div class='message-text'>{assistant_response}</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
""" | |
return new_chat, "" | |
# Connect provider change event | |
llm_provider.change( | |
fn=on_provider_change, | |
inputs=[llm_provider], | |
outputs=[openai_compatible_row] | |
) | |
generate_btn.click( | |
fn=generate_course_wrapper, | |
inputs=[topic_input, difficulty_input, lesson_count, llm_provider, api_key_input, endpoint_url_input, model_name_input], | |
outputs=[ | |
lessons_output, flashcards_output, quizzes_output, quiz_submit_btn, image_gallery, image_details | |
] | |
) | |
chat_btn.click( | |
fn=handle_chat, | |
inputs=[chat_input, chat_display], | |
outputs=[chat_display, chat_input] | |
) | |
# Use a much simpler approach with direct JavaScript execution | |
quiz_submit_btn.click( | |
fn=None, # No Python function needed | |
js=""" | |
function() { | |
// Find all quiz questions and process them | |
const questions = document.querySelectorAll('.quiz-question'); | |
if (questions.length === 0) { | |
alert('No quiz questions found!'); | |
return; | |
} | |
let score = 0; | |
let total = questions.length; | |
let hasAnswers = false; | |
questions.forEach((question, idx) => { | |
const radios = question.querySelectorAll('input[type="radio"]'); | |
const correctAnswer = question.dataset.correct; | |
const explanation = question.dataset.explanation || ''; | |
let selectedRadio = null; | |
radios.forEach(radio => { | |
if (radio.checked) { | |
selectedRadio = radio; | |
hasAnswers = true; | |
} | |
}); | |
// Create or find feedback element | |
let feedback = question.querySelector('.quiz-feedback'); | |
if (!feedback) { | |
feedback = document.createElement('div'); | |
feedback.className = 'quiz-feedback'; | |
question.appendChild(feedback); | |
} | |
if (selectedRadio) { | |
const userAnswer = selectedRadio.value; | |
if (userAnswer === correctAnswer) { | |
score++; | |
feedback.innerHTML = `<div style="background: #d4edda; color: #155724; padding: 1rem; border-radius: 6px; margin-top: 1rem;">β <strong>Correct!</strong> ${explanation}</div>`; | |
} else { | |
feedback.innerHTML = `<div style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 6px; margin-top: 1rem;">β <strong>Incorrect.</strong> The correct answer is <strong>${correctAnswer}</strong>. ${explanation}</div>`; | |
} | |
} else { | |
feedback.innerHTML = `<div style="background: #fff3cd; color: #856404; padding: 1rem; border-radius: 6px; margin-top: 1rem;">β οΈ <strong>No answer selected.</strong> The correct answer is <strong>${correctAnswer}</strong>. ${explanation}</div>`; | |
} | |
feedback.style.display = 'block'; | |
}); | |
if (hasAnswers) { | |
const percentage = Math.round((score / total) * 100); | |
// Create or find results container | |
let resultsContainer = document.querySelector('.quiz-results'); | |
if (!resultsContainer) { | |
resultsContainer = document.createElement('div'); | |
resultsContainer.className = 'quiz-results'; | |
resultsContainer.style.cssText = 'margin-top: 2rem; padding: 1.5rem; background: linear-gradient(135deg, #667eea, #764ba2); color: white; border-radius: 8px; text-align: center; font-size: 1.1rem;'; | |
document.querySelector('.quiz-container').appendChild(resultsContainer); | |
} | |
let message = ''; | |
if (percentage >= 80) { | |
message = 'π Excellent work!'; | |
} else if (percentage >= 60) { | |
message = 'π Good job!'; | |
} else { | |
message = 'π Keep studying!'; | |
} | |
resultsContainer.innerHTML = ` | |
<div style="font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem;">π Final Score: ${score}/${total} (${percentage}%)</div> | |
<p>${message}</p> | |
`; | |
resultsContainer.style.display = 'block'; | |
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
} else { | |
alert('Please answer at least one question before submitting!'); | |
} | |
} | |
""" | |
) | |
return interface | |
def launch_app(share: bool = False, debug: bool = False) -> None: | |
"""Launch the Course Creator application""" | |
interface = create_coursecrafter_interface() | |
interface.launch( | |
share=share, | |
debug=debug, | |
server_name="0.0.0.0", | |
server_port=7862 | |
) | |
if __name__ == "__main__": | |
launch_app(debug=True) |