Spaces:
Sleeping
Sleeping
Commit
Β·
074e8ce
0
Parent(s):
Initial commit: Explainor - AI agent with persona voices
Browse filesMCP's 1st Birthday Hackathon submission
- Gradio 6 UI with 6 personas
- DuckDuckGo web search integration
- ElevenLabs TTS with persona-matched voices
- Modal deployment config
- Agent logic with reasoning steps
Track: MCP in Action (Creative)
Team: kaiser-data
π€ Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- .env.example +2 -0
- .gitignore +9 -0
- README.md +126 -0
- app.py +268 -0
- modal_app.py +50 -0
- requirements.txt +6 -0
- src/__init__.py +15 -0
- src/agent.py +268 -0
- src/personas.py +74 -0
- src/tts.py +59 -0
.env.example
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
NEBIUS_API_KEY=your_nebius_api_key_here
|
| 2 |
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
dist/
|
| 8 |
+
build/
|
| 9 |
+
.modal/
|
README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# π Explainor
|
| 2 |
+
|
| 3 |
+
> **Learn anything through the voice of your favorite characters!**
|
| 4 |
+
|
| 5 |
+
[](https://huggingface.co/MCP-1st-Birthday)
|
| 6 |
+
[](https://huggingface.co/MCP-1st-Birthday)
|
| 7 |
+
[](https://huggingface.co/MCP-1st-Birthday)
|
| 8 |
+
|
| 9 |
+
**Tags:** `mcp-in-action-track-creative`
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## π What is Explainor?
|
| 14 |
+
|
| 15 |
+
Explainor is an AI agent that takes any topic you want to learn about and explains it through the voice of fun characters! Choose from 6 unique personas:
|
| 16 |
+
|
| 17 |
+
| Persona | Style |
|
| 18 |
+
|---------|-------|
|
| 19 |
+
| πΆ **5-Year-Old** | Simple words, excited, curious questions |
|
| 20 |
+
| π¨βπ³ **Gordon Ramsay** | Intense, food metaphors, "It's RAW!" |
|
| 21 |
+
| π΄ββ οΈ **Pirate** | "Arrr!", treasure metaphors, swashbuckling |
|
| 22 |
+
| π **Shakespeare** | Dramatic, old English, theatrical |
|
| 23 |
+
| π **Surfer Dude** | "Brooo", chill vibes, wave metaphors |
|
| 24 |
+
| π§ **Yoda** | Inverted syntax, wise, Force references |
|
| 25 |
+
|
| 26 |
+
## π¬ Demo
|
| 27 |
+
|
| 28 |
+
[Demo Video Placeholder]
|
| 29 |
+
|
| 30 |
+
## π οΈ How It Works
|
| 31 |
+
|
| 32 |
+
1. **Enter a topic** - Anything from "Quantum Computing" to "How do volcanoes work?"
|
| 33 |
+
2. **Choose a persona** - Pick your favorite character
|
| 34 |
+
3. **Watch the magic** - The AI agent:
|
| 35 |
+
- π Researches your topic using web search
|
| 36 |
+
- π§ Shows its reasoning process
|
| 37 |
+
- βοΈ Transforms the explanation into the character's voice
|
| 38 |
+
- π Reads it aloud with a matching voice!
|
| 39 |
+
|
| 40 |
+
## π Tech Stack
|
| 41 |
+
|
| 42 |
+
- **LLM**: [Nebius AI](https://nebius.com) - Llama 3.3 70B for intelligent explanations
|
| 43 |
+
- **TTS**: [ElevenLabs](https://elevenlabs.io) - Realistic voice synthesis with character-matched voices
|
| 44 |
+
- **Web Search**: DuckDuckGo API for topic research
|
| 45 |
+
- **Frontend**: [Gradio](https://gradio.app) - Beautiful, responsive UI
|
| 46 |
+
- **Deployment**: [Modal](https://modal.com) - Serverless infrastructure
|
| 47 |
+
|
| 48 |
+
## π» Local Development
|
| 49 |
+
|
| 50 |
+
### Prerequisites
|
| 51 |
+
|
| 52 |
+
- Python 3.11+
|
| 53 |
+
- Nebius API key
|
| 54 |
+
- ElevenLabs API key
|
| 55 |
+
|
| 56 |
+
### Setup
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
# Clone the repository
|
| 60 |
+
git clone https://huggingface.co/spaces/MCP-1st-Birthday/explainor
|
| 61 |
+
|
| 62 |
+
# Install dependencies
|
| 63 |
+
pip install -r requirements.txt
|
| 64 |
+
|
| 65 |
+
# Set up environment variables
|
| 66 |
+
cp .env.example .env
|
| 67 |
+
# Edit .env with your API keys
|
| 68 |
+
|
| 69 |
+
# Run locally
|
| 70 |
+
python app.py
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### Environment Variables
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
NEBIUS_API_KEY=your_nebius_api_key_here
|
| 77 |
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
## π Deployment
|
| 81 |
+
|
| 82 |
+
### Modal Deployment
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
# Set up Modal secrets
|
| 86 |
+
modal secret create nebius-api-key NEBIUS_API_KEY=your_key
|
| 87 |
+
modal secret create elevenlabs-api-key ELEVENLABS_API_KEY=your_key
|
| 88 |
+
|
| 89 |
+
# Deploy
|
| 90 |
+
modal deploy modal_app.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Hugging Face Spaces
|
| 94 |
+
|
| 95 |
+
This app is designed to run on Hugging Face Spaces with the Gradio SDK.
|
| 96 |
+
|
| 97 |
+
## π Project Structure
|
| 98 |
+
|
| 99 |
+
```
|
| 100 |
+
explainor/
|
| 101 |
+
βββ app.py # Main Gradio application
|
| 102 |
+
βββ modal_app.py # Modal deployment config
|
| 103 |
+
βββ requirements.txt # Python dependencies
|
| 104 |
+
βββ src/
|
| 105 |
+
β βββ __init__.py
|
| 106 |
+
β βββ personas.py # Persona definitions & voice mappings
|
| 107 |
+
β βββ agent.py # Agent logic & web search
|
| 108 |
+
β βββ tts.py # ElevenLabs integration
|
| 109 |
+
βββ README.md
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
## π Hackathon Submission
|
| 113 |
+
|
| 114 |
+
- **Event**: MCP's 1st Birthday Hackathon
|
| 115 |
+
- **Track**: MCP in Action (Track 2)
|
| 116 |
+
- **Category**: Creative
|
| 117 |
+
- **Team**: kaiser-data
|
| 118 |
+
- **Sponsor Integration**: ElevenLabs for text-to-speech
|
| 119 |
+
|
| 120 |
+
## π License
|
| 121 |
+
|
| 122 |
+
MIT License - Feel free to use and modify!
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
**Made with β€οΈ for MCP's 1st Birthday Hackathon**
|
app.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explainor - AI Agent that explains any topic in fun persona voices.
|
| 2 |
+
|
| 3 |
+
MCP's 1st Birthday Hackathon Submission
|
| 4 |
+
Track: MCP in Action (Creative)
|
| 5 |
+
Team: kaiser-data
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import tempfile
|
| 10 |
+
import gradio as gr
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
|
| 13 |
+
from src.personas import PERSONAS, get_persona_names, get_persona
|
| 14 |
+
from src.agent import run_agent
|
| 15 |
+
from src.tts import generate_speech
|
| 16 |
+
|
| 17 |
+
# Load environment variables
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def format_sources(sources: list[dict]) -> str:
|
| 22 |
+
"""Format sources as markdown."""
|
| 23 |
+
if not sources:
|
| 24 |
+
return "*No external sources used*"
|
| 25 |
+
|
| 26 |
+
md = ""
|
| 27 |
+
for i, src in enumerate(sources, 1):
|
| 28 |
+
if src.get("url"):
|
| 29 |
+
md += f"{i}. [{src['title']}]({src['url']})\n"
|
| 30 |
+
else:
|
| 31 |
+
md += f"{i}. {src['title']} ({src.get('source', 'General')})\n"
|
| 32 |
+
return md
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def explain_topic(topic: str, persona_name: str, progress=gr.Progress()):
|
| 36 |
+
"""Main function to explain a topic in a persona's voice.
|
| 37 |
+
|
| 38 |
+
Returns: (explanation_text, audio_path, sources_md, steps_md)
|
| 39 |
+
"""
|
| 40 |
+
if not topic.strip():
|
| 41 |
+
return (
|
| 42 |
+
"Please enter a topic to explain!",
|
| 43 |
+
None,
|
| 44 |
+
"",
|
| 45 |
+
"β No topic provided",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
if not persona_name:
|
| 49 |
+
persona_name = "5-Year-Old"
|
| 50 |
+
|
| 51 |
+
steps_log = []
|
| 52 |
+
explanation = ""
|
| 53 |
+
sources = []
|
| 54 |
+
voice_id = None
|
| 55 |
+
|
| 56 |
+
# Run the agent pipeline
|
| 57 |
+
progress(0, desc="Starting...")
|
| 58 |
+
|
| 59 |
+
for update in run_agent(topic, persona_name):
|
| 60 |
+
if update["type"] == "step":
|
| 61 |
+
step_text = f"**{update['title']}**\n{update['content']}"
|
| 62 |
+
steps_log.append(step_text)
|
| 63 |
+
|
| 64 |
+
if update["step"] == "research":
|
| 65 |
+
progress(0.2, desc="Researching...")
|
| 66 |
+
elif update["step"] == "research_done":
|
| 67 |
+
progress(0.4, desc="Research complete")
|
| 68 |
+
if "sources" in update:
|
| 69 |
+
sources = update["sources"]
|
| 70 |
+
elif update["step"] == "generating":
|
| 71 |
+
progress(0.6, desc="Generating explanation...")
|
| 72 |
+
|
| 73 |
+
elif update["type"] == "result":
|
| 74 |
+
explanation = update["explanation"]
|
| 75 |
+
sources = update.get("sources", sources)
|
| 76 |
+
voice_id = update["voice_id"]
|
| 77 |
+
progress(0.8, desc="Explanation ready!")
|
| 78 |
+
|
| 79 |
+
# Format the steps log
|
| 80 |
+
steps_md = "\n\n---\n\n".join(steps_log)
|
| 81 |
+
|
| 82 |
+
# Generate audio
|
| 83 |
+
audio_path = None
|
| 84 |
+
if explanation and voice_id:
|
| 85 |
+
progress(0.9, desc="Generating audio...")
|
| 86 |
+
try:
|
| 87 |
+
audio_bytes = generate_speech(explanation, voice_id)
|
| 88 |
+
# Save to temp file for Gradio
|
| 89 |
+
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
| 90 |
+
f.write(audio_bytes)
|
| 91 |
+
audio_path = f.name
|
| 92 |
+
progress(1.0, desc="Done!")
|
| 93 |
+
except Exception as e:
|
| 94 |
+
steps_log.append(f"**β οΈ Audio generation failed**\n{str(e)}")
|
| 95 |
+
steps_md = "\n\n---\n\n".join(steps_log)
|
| 96 |
+
progress(1.0, desc="Done (no audio)")
|
| 97 |
+
|
| 98 |
+
# Format sources
|
| 99 |
+
sources_md = format_sources(sources)
|
| 100 |
+
|
| 101 |
+
return explanation, audio_path, sources_md, steps_md
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# Build the Gradio interface
|
| 105 |
+
def create_app():
|
| 106 |
+
"""Create and configure the Gradio app."""
|
| 107 |
+
|
| 108 |
+
# Custom CSS for better styling
|
| 109 |
+
css = """
|
| 110 |
+
.gradio-container {
|
| 111 |
+
max-width: 900px !important;
|
| 112 |
+
margin: auto !important;
|
| 113 |
+
}
|
| 114 |
+
.persona-dropdown {
|
| 115 |
+
font-size: 1.1em !important;
|
| 116 |
+
}
|
| 117 |
+
#title-row {
|
| 118 |
+
text-align: center;
|
| 119 |
+
margin-bottom: 1rem;
|
| 120 |
+
}
|
| 121 |
+
.explanation-box {
|
| 122 |
+
font-size: 1.15em;
|
| 123 |
+
line-height: 1.6;
|
| 124 |
+
padding: 1rem;
|
| 125 |
+
background: linear-gradient(135deg, #667eea11 0%, #764ba211 100%);
|
| 126 |
+
border-radius: 10px;
|
| 127 |
+
}
|
| 128 |
+
.steps-accordion {
|
| 129 |
+
font-size: 0.95em;
|
| 130 |
+
}
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
with gr.Blocks(
|
| 134 |
+
title="Explainor - AI Persona Explanations",
|
| 135 |
+
css=css,
|
| 136 |
+
theme=gr.themes.Soft(
|
| 137 |
+
primary_hue="violet",
|
| 138 |
+
secondary_hue="blue",
|
| 139 |
+
),
|
| 140 |
+
) as app:
|
| 141 |
+
# Header
|
| 142 |
+
gr.Markdown(
|
| 143 |
+
"""
|
| 144 |
+
# π Explainor
|
| 145 |
+
|
| 146 |
+
**Learn anything through the voice of your favorite characters!**
|
| 147 |
+
|
| 148 |
+
Enter any topic and choose a persona. The AI will research your topic,
|
| 149 |
+
transform the explanation into that character's unique voice, and read it aloud.
|
| 150 |
+
""",
|
| 151 |
+
elem_id="title-row",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
with gr.Row():
|
| 155 |
+
with gr.Column(scale=2):
|
| 156 |
+
topic_input = gr.Textbox(
|
| 157 |
+
label="π What do you want to learn about?",
|
| 158 |
+
placeholder="e.g., Blockchain, Photosynthesis, Black Holes...",
|
| 159 |
+
lines=1,
|
| 160 |
+
max_lines=1,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
with gr.Column(scale=1):
|
| 164 |
+
# Build persona choices with emojis
|
| 165 |
+
persona_choices = [
|
| 166 |
+
f"{PERSONAS[name]['emoji']} {name}"
|
| 167 |
+
for name in get_persona_names()
|
| 168 |
+
]
|
| 169 |
+
persona_dropdown = gr.Dropdown(
|
| 170 |
+
choices=persona_choices,
|
| 171 |
+
value=persona_choices[0],
|
| 172 |
+
label="π Choose your explainer",
|
| 173 |
+
elem_classes=["persona-dropdown"],
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
explain_btn = gr.Button(
|
| 177 |
+
"β¨ Explain it to me!",
|
| 178 |
+
variant="primary",
|
| 179 |
+
size="lg",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Output section
|
| 183 |
+
with gr.Row():
|
| 184 |
+
with gr.Column():
|
| 185 |
+
explanation_output = gr.Textbox(
|
| 186 |
+
label="π Explanation",
|
| 187 |
+
lines=8,
|
| 188 |
+
max_lines=15,
|
| 189 |
+
elem_classes=["explanation-box"],
|
| 190 |
+
show_copy_button=True,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
audio_output = gr.Audio(
|
| 194 |
+
label="π Listen to the explanation",
|
| 195 |
+
type="filepath",
|
| 196 |
+
autoplay=False,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
with gr.Row():
|
| 200 |
+
with gr.Column():
|
| 201 |
+
with gr.Accordion("π Sources", open=False):
|
| 202 |
+
sources_output = gr.Markdown("")
|
| 203 |
+
|
| 204 |
+
with gr.Column():
|
| 205 |
+
with gr.Accordion("π§ Agent Reasoning", open=False):
|
| 206 |
+
steps_output = gr.Markdown(
|
| 207 |
+
"",
|
| 208 |
+
elem_classes=["steps-accordion"],
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Example topics
|
| 212 |
+
gr.Examples(
|
| 213 |
+
examples=[
|
| 214 |
+
["Quantum Computing", f"{PERSONAS['5-Year-Old']['emoji']} 5-Year-Old"],
|
| 215 |
+
["Blockchain", f"{PERSONAS['Gordon Ramsay']['emoji']} Gordon Ramsay"],
|
| 216 |
+
["Black Holes", f"{PERSONAS['Pirate']['emoji']} Pirate"],
|
| 217 |
+
["Machine Learning", f"{PERSONAS['Shakespeare']['emoji']} Shakespeare"],
|
| 218 |
+
["Climate Change", f"{PERSONAS['Surfer Dude']['emoji']} Surfer Dude"],
|
| 219 |
+
["The Force", f"{PERSONAS['Yoda']['emoji']} Yoda"],
|
| 220 |
+
],
|
| 221 |
+
inputs=[topic_input, persona_dropdown],
|
| 222 |
+
label="Try these examples:",
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# Footer
|
| 226 |
+
gr.Markdown(
|
| 227 |
+
"""
|
| 228 |
+
---
|
| 229 |
+
**Built for MCP's 1st Birthday Hackathon** | Track: MCP in Action (Creative)
|
| 230 |
+
|
| 231 |
+
Powered by: [Nebius AI](https://nebius.com) (LLM) + [ElevenLabs](https://elevenlabs.io) (TTS)
|
| 232 |
+
|
| 233 |
+
Made with β€οΈ by **kaiser-data**
|
| 234 |
+
""",
|
| 235 |
+
elem_id="footer",
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Event handler
|
| 239 |
+
def process_and_explain(topic, persona_with_emoji):
|
| 240 |
+
# Extract persona name (remove emoji prefix)
|
| 241 |
+
persona_name = persona_with_emoji.split(" ", 1)[1] if " " in persona_with_emoji else persona_with_emoji
|
| 242 |
+
return explain_topic(topic, persona_name)
|
| 243 |
+
|
| 244 |
+
explain_btn.click(
|
| 245 |
+
fn=process_and_explain,
|
| 246 |
+
inputs=[topic_input, persona_dropdown],
|
| 247 |
+
outputs=[explanation_output, audio_output, sources_output, steps_output],
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Also trigger on Enter key in topic input
|
| 251 |
+
topic_input.submit(
|
| 252 |
+
fn=process_and_explain,
|
| 253 |
+
inputs=[topic_input, persona_dropdown],
|
| 254 |
+
outputs=[explanation_output, audio_output, sources_output, steps_output],
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
return app
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# Create the app
|
| 261 |
+
app = create_app()
|
| 262 |
+
|
| 263 |
+
if __name__ == "__main__":
|
| 264 |
+
app.launch(
|
| 265 |
+
server_name="0.0.0.0",
|
| 266 |
+
server_port=7860,
|
| 267 |
+
share=False,
|
| 268 |
+
)
|
modal_app.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modal deployment configuration for Explainor.
|
| 2 |
+
|
| 3 |
+
Deploy with: modal deploy modal_app.py
|
| 4 |
+
Run locally: modal serve modal_app.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import modal
|
| 9 |
+
|
| 10 |
+
# Define the Modal app
|
| 11 |
+
app = modal.App("explainor")
|
| 12 |
+
|
| 13 |
+
# Create image with dependencies
|
| 14 |
+
image = (
|
| 15 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 16 |
+
.pip_install(
|
| 17 |
+
"gradio>=5.0.0",
|
| 18 |
+
"elevenlabs>=1.0.0",
|
| 19 |
+
"httpx>=0.25.0",
|
| 20 |
+
"python-dotenv>=1.0.0",
|
| 21 |
+
)
|
| 22 |
+
.copy_local_dir("src", "/app/src")
|
| 23 |
+
.copy_local_file("app.py", "/app/app.py")
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@app.function(
|
| 28 |
+
image=image,
|
| 29 |
+
secrets=[
|
| 30 |
+
modal.Secret.from_name("nebius-api-key"),
|
| 31 |
+
modal.Secret.from_name("elevenlabs-api-key"),
|
| 32 |
+
],
|
| 33 |
+
timeout=600,
|
| 34 |
+
allow_concurrent_inputs=10,
|
| 35 |
+
)
|
| 36 |
+
@modal.asgi_app()
|
| 37 |
+
def serve():
|
| 38 |
+
"""Serve the Gradio app."""
|
| 39 |
+
import sys
|
| 40 |
+
sys.path.insert(0, "/app")
|
| 41 |
+
|
| 42 |
+
from app import app as gradio_app
|
| 43 |
+
return gradio_app
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# For local testing
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
# Run with: python modal_app.py
|
| 49 |
+
print("Run with: modal serve modal_app.py")
|
| 50 |
+
print("Or deploy: modal deploy modal_app.py")
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
elevenlabs>=1.0.0
|
| 3 |
+
openai>=1.0.0
|
| 4 |
+
httpx>=0.25.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
modal>=0.64.0
|
src/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explainor - AI agent that explains topics in persona voices."""
|
| 2 |
+
|
| 3 |
+
from .personas import PERSONAS, get_persona, get_persona_names
|
| 4 |
+
from .agent import run_agent, research_topic
|
| 5 |
+
from .tts import generate_speech, generate_speech_file
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"PERSONAS",
|
| 9 |
+
"get_persona",
|
| 10 |
+
"get_persona_names",
|
| 11 |
+
"run_agent",
|
| 12 |
+
"research_topic",
|
| 13 |
+
"generate_speech",
|
| 14 |
+
"generate_speech_file",
|
| 15 |
+
]
|
src/agent.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explainor Agent - Research and explain topics in persona voices."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import httpx
|
| 6 |
+
from typing import Generator
|
| 7 |
+
|
| 8 |
+
from .personas import get_persona
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Nebius API configuration (OpenAI-compatible)
|
| 12 |
+
NEBIUS_API_BASE = "https://api.studio.nebius.com/v1"
|
| 13 |
+
NEBIUS_MODEL = "meta-llama/Llama-3.3-70B-Instruct"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_nebius_client():
|
| 17 |
+
"""Get configured httpx client for Nebius API."""
|
| 18 |
+
api_key = os.getenv("NEBIUS_API_KEY")
|
| 19 |
+
if not api_key:
|
| 20 |
+
raise ValueError("NEBIUS_API_KEY environment variable not set")
|
| 21 |
+
return api_key
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def web_search(query: str) -> dict:
|
| 25 |
+
"""Perform web search using DuckDuckGo (no API key needed).
|
| 26 |
+
|
| 27 |
+
Returns structured search results.
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
# Use DuckDuckGo HTML search (no API needed)
|
| 31 |
+
headers = {
|
| 32 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
with httpx.Client(timeout=10.0) as client:
|
| 36 |
+
# DuckDuckGo instant answer API
|
| 37 |
+
resp = client.get(
|
| 38 |
+
"https://api.duckduckgo.com/",
|
| 39 |
+
params={
|
| 40 |
+
"q": query,
|
| 41 |
+
"format": "json",
|
| 42 |
+
"no_html": "1",
|
| 43 |
+
"skip_disambig": "1",
|
| 44 |
+
},
|
| 45 |
+
headers=headers,
|
| 46 |
+
)
|
| 47 |
+
data = resp.json()
|
| 48 |
+
|
| 49 |
+
results = []
|
| 50 |
+
|
| 51 |
+
# Abstract (main answer)
|
| 52 |
+
if data.get("Abstract"):
|
| 53 |
+
results.append({
|
| 54 |
+
"title": data.get("Heading", "Overview"),
|
| 55 |
+
"snippet": data["Abstract"],
|
| 56 |
+
"source": data.get("AbstractSource", "DuckDuckGo"),
|
| 57 |
+
"url": data.get("AbstractURL", ""),
|
| 58 |
+
})
|
| 59 |
+
|
| 60 |
+
# Related topics
|
| 61 |
+
for topic in data.get("RelatedTopics", [])[:3]:
|
| 62 |
+
if isinstance(topic, dict) and topic.get("Text"):
|
| 63 |
+
results.append({
|
| 64 |
+
"title": topic.get("Text", "")[:50] + "...",
|
| 65 |
+
"snippet": topic.get("Text", ""),
|
| 66 |
+
"source": "DuckDuckGo",
|
| 67 |
+
"url": topic.get("FirstURL", ""),
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
# If no results, try a simpler search
|
| 71 |
+
if not results:
|
| 72 |
+
results.append({
|
| 73 |
+
"title": f"Search: {query}",
|
| 74 |
+
"snippet": f"Topic: {query}. Please explain this concept based on general knowledge.",
|
| 75 |
+
"source": "General Knowledge",
|
| 76 |
+
"url": "",
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
return {"results": results, "query": query}
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return {
|
| 83 |
+
"results": [{
|
| 84 |
+
"title": f"Search: {query}",
|
| 85 |
+
"snippet": f"Topic: {query}. Please explain this concept based on general knowledge.",
|
| 86 |
+
"source": "General Knowledge",
|
| 87 |
+
"url": "",
|
| 88 |
+
}],
|
| 89 |
+
"query": query,
|
| 90 |
+
"error": str(e),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def call_llm(messages: list[dict], max_tokens: int = 1500) -> str:
|
| 95 |
+
"""Call Nebius LLM API."""
|
| 96 |
+
api_key = get_nebius_client()
|
| 97 |
+
|
| 98 |
+
with httpx.Client(timeout=60.0) as client:
|
| 99 |
+
resp = client.post(
|
| 100 |
+
f"{NEBIUS_API_BASE}/chat/completions",
|
| 101 |
+
headers={
|
| 102 |
+
"Authorization": f"Bearer {api_key}",
|
| 103 |
+
"Content-Type": "application/json",
|
| 104 |
+
},
|
| 105 |
+
json={
|
| 106 |
+
"model": NEBIUS_MODEL,
|
| 107 |
+
"messages": messages,
|
| 108 |
+
"max_tokens": max_tokens,
|
| 109 |
+
"temperature": 0.8,
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
resp.raise_for_status()
|
| 113 |
+
data = resp.json()
|
| 114 |
+
return data["choices"][0]["message"]["content"]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def research_topic(topic: str) -> tuple[str, list[dict]]:
|
| 118 |
+
"""Research a topic using web search.
|
| 119 |
+
|
| 120 |
+
Returns: (research_summary, sources_list)
|
| 121 |
+
"""
|
| 122 |
+
# Perform search
|
| 123 |
+
search_results = web_search(topic)
|
| 124 |
+
|
| 125 |
+
# Format research for the agent
|
| 126 |
+
research_text = f"## Research on: {topic}\n\n"
|
| 127 |
+
sources = []
|
| 128 |
+
|
| 129 |
+
for i, result in enumerate(search_results.get("results", []), 1):
|
| 130 |
+
research_text += f"### Source {i}: {result['title']}\n"
|
| 131 |
+
research_text += f"{result['snippet']}\n\n"
|
| 132 |
+
if result.get("url"):
|
| 133 |
+
sources.append({
|
| 134 |
+
"title": result["title"],
|
| 135 |
+
"url": result["url"],
|
| 136 |
+
"source": result.get("source", "Web"),
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
return research_text, sources
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def generate_explanation(
|
| 143 |
+
topic: str,
|
| 144 |
+
persona_name: str,
|
| 145 |
+
research: str,
|
| 146 |
+
) -> Generator[dict, None, None]:
|
| 147 |
+
"""Generate explanation in persona voice, yielding steps.
|
| 148 |
+
|
| 149 |
+
Yields dicts with: {"step": str, "content": str}
|
| 150 |
+
"""
|
| 151 |
+
persona = get_persona(persona_name)
|
| 152 |
+
|
| 153 |
+
# Step 1: Acknowledge the task
|
| 154 |
+
yield {
|
| 155 |
+
"step": "understanding",
|
| 156 |
+
"title": "π Understanding the topic",
|
| 157 |
+
"content": f"Researching '{topic}' to gather key information...",
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
# Step 2: Show research
|
| 161 |
+
yield {
|
| 162 |
+
"step": "research",
|
| 163 |
+
"title": "π Research complete",
|
| 164 |
+
"content": f"Found information about {topic}. Now transforming into {persona_name} voice...",
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
# Step 3: Generate the explanation
|
| 168 |
+
messages = [
|
| 169 |
+
{
|
| 170 |
+
"role": "system",
|
| 171 |
+
"content": f"""{persona['system_prompt']}
|
| 172 |
+
|
| 173 |
+
You are explaining a topic to someone. Your explanation should be:
|
| 174 |
+
1. Entertaining and fully in character
|
| 175 |
+
2. Educational - actually explain the concept clearly
|
| 176 |
+
3. About 150-200 words (suitable for audio)
|
| 177 |
+
4. Natural spoken language (will be read aloud)
|
| 178 |
+
|
| 179 |
+
Do NOT break character. Do NOT use markdown formatting or bullet points.
|
| 180 |
+
Just speak naturally as your character would.""",
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"role": "user",
|
| 184 |
+
"content": f"""Here's some research on the topic:
|
| 185 |
+
|
| 186 |
+
{research}
|
| 187 |
+
|
| 188 |
+
Now explain "{topic}" in your unique voice and style. Make it fun and educational!""",
|
| 189 |
+
},
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
explanation = call_llm(messages)
|
| 193 |
+
|
| 194 |
+
yield {
|
| 195 |
+
"step": "explanation",
|
| 196 |
+
"title": f"{persona['emoji']} Explanation ready",
|
| 197 |
+
"content": explanation,
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def run_agent(topic: str, persona_name: str) -> Generator[dict, None, None]:
|
| 202 |
+
"""Run the full agent pipeline.
|
| 203 |
+
|
| 204 |
+
Yields progress updates and final results.
|
| 205 |
+
"""
|
| 206 |
+
# Step 1: Research
|
| 207 |
+
yield {
|
| 208 |
+
"type": "step",
|
| 209 |
+
"step": "research",
|
| 210 |
+
"title": "π Searching the web",
|
| 211 |
+
"content": f"Looking up information about '{topic}'...",
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
research, sources = research_topic(topic)
|
| 215 |
+
|
| 216 |
+
yield {
|
| 217 |
+
"type": "step",
|
| 218 |
+
"step": "research_done",
|
| 219 |
+
"title": "π Research complete",
|
| 220 |
+
"content": f"Found {len(sources)} sources. Processing...",
|
| 221 |
+
"sources": sources,
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
# Step 2: Generate explanation
|
| 225 |
+
persona = get_persona(persona_name)
|
| 226 |
+
|
| 227 |
+
yield {
|
| 228 |
+
"type": "step",
|
| 229 |
+
"step": "generating",
|
| 230 |
+
"title": f"{persona['emoji']} Channeling {persona_name}",
|
| 231 |
+
"content": "Transforming research into persona voice...",
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
messages = [
|
| 235 |
+
{
|
| 236 |
+
"role": "system",
|
| 237 |
+
"content": f"""{persona['system_prompt']}
|
| 238 |
+
|
| 239 |
+
You are explaining a topic to someone. Your explanation should be:
|
| 240 |
+
1. Entertaining and fully in character
|
| 241 |
+
2. Educational - actually explain the concept clearly
|
| 242 |
+
3. About 150-200 words (suitable for text-to-speech)
|
| 243 |
+
4. Natural spoken language (will be read aloud)
|
| 244 |
+
5. Engaging and memorable
|
| 245 |
+
|
| 246 |
+
Do NOT break character. Do NOT use markdown, bullet points, or special formatting.
|
| 247 |
+
Just speak naturally as your character would.""",
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"role": "user",
|
| 251 |
+
"content": f"""Research on the topic:
|
| 252 |
+
|
| 253 |
+
{research}
|
| 254 |
+
|
| 255 |
+
Now explain "{topic}" in your unique {persona_name} voice and style. Make it fun, memorable, and educational!""",
|
| 256 |
+
},
|
| 257 |
+
]
|
| 258 |
+
|
| 259 |
+
explanation = call_llm(messages)
|
| 260 |
+
|
| 261 |
+
yield {
|
| 262 |
+
"type": "result",
|
| 263 |
+
"explanation": explanation,
|
| 264 |
+
"sources": sources,
|
| 265 |
+
"persona": persona_name,
|
| 266 |
+
"persona_emoji": persona["emoji"],
|
| 267 |
+
"voice_id": persona["voice_id"],
|
| 268 |
+
}
|
src/personas.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persona definitions for Explainor."""
|
| 2 |
+
|
| 3 |
+
PERSONAS = {
|
| 4 |
+
"5-Year-Old": {
|
| 5 |
+
"system_prompt": """You are an excited, curious 5-year-old child explaining things.
|
| 6 |
+
Use very simple words that a child would know. Be enthusiastic and ask rhetorical questions.
|
| 7 |
+
Say things like "Ooh!" and "Wow!" and "You know what?"
|
| 8 |
+
Compare everything to toys, candy, cartoons, and playground activities.
|
| 9 |
+
Keep sentences very short. Use lots of exclamation marks!""",
|
| 10 |
+
"voice_id": "jBpfuIE2acCO8z3wKNLl", # "Aria" - young, enthusiastic
|
| 11 |
+
"emoji": "πΆ",
|
| 12 |
+
},
|
| 13 |
+
"Gordon Ramsay": {
|
| 14 |
+
"system_prompt": """You are Gordon Ramsay, the intense celebrity chef, explaining things.
|
| 15 |
+
Be passionate, sometimes angry, and use lots of food and cooking metaphors.
|
| 16 |
+
Occasionally call things "bloody brilliant" or express frustration at complexity.
|
| 17 |
+
Compare concepts to cooking techniques, ingredients, and kitchen disasters.
|
| 18 |
+
Use phrases like "Listen here!", "It's RAW!", "Absolutely stunning!", and "Donkey!".
|
| 19 |
+
Be dramatic but ultimately make the explanation clear.""",
|
| 20 |
+
"voice_id": "N2lVS1w4EtoT3dr4eOWO", # "Callum" - British, intense
|
| 21 |
+
"emoji": "π¨βπ³",
|
| 22 |
+
},
|
| 23 |
+
"Pirate": {
|
| 24 |
+
"system_prompt": """You are a theatrical pirate captain explaining things.
|
| 25 |
+
Use pirate slang: "Arrr!", "Ahoy!", "Shiver me timbers!", "Ye", "Aye", "Blimey!"
|
| 26 |
+
Compare everything to treasure, ships, the sea, and pirate adventures.
|
| 27 |
+
Talk about concepts like they're parts of a treasure map or sea voyage.
|
| 28 |
+
Be dramatic and swashbuckling. Mention your crew, your ship, and rum occasionally.
|
| 29 |
+
End with something about setting sail for knowledge.""",
|
| 30 |
+
"voice_id": "TX3LPaxmHKxFdv7VOQHJ", # "Liam" - gruff, theatrical
|
| 31 |
+
"emoji": "π΄ββ οΈ",
|
| 32 |
+
},
|
| 33 |
+
"Shakespeare": {
|
| 34 |
+
"system_prompt": """You are William Shakespeare explaining modern concepts in Elizabethan style.
|
| 35 |
+
Use thee, thou, thy, hath, doth, 'tis, wherefore, prithee, forsooth, verily.
|
| 36 |
+
Be dramatic and poetic. Use metaphors from nature, love, and theater.
|
| 37 |
+
Occasionally quote or parody your own famous lines.
|
| 38 |
+
Structure explanations like soliloquies with dramatic pauses.
|
| 39 |
+
Compare technology and modern things to courtly intrigue and theatrical performance.""",
|
| 40 |
+
"voice_id": "onwK4e9ZLuTAKqWW03F9", # "Daniel" - theatrical British
|
| 41 |
+
"emoji": "π",
|
| 42 |
+
},
|
| 43 |
+
"Surfer Dude": {
|
| 44 |
+
"system_prompt": """You are a laid-back California surfer dude explaining things.
|
| 45 |
+
Use surfer slang: "Bro", "Dude", "Gnarly", "Radical", "Stoked", "Totally", "Like", "Vibes".
|
| 46 |
+
Compare everything to surfing, waves, the ocean, and beach life.
|
| 47 |
+
Be super chill and positive. Everything is awesome and gives good vibes.
|
| 48 |
+
Use "like" as filler. Talk about concepts like they're waves to ride.
|
| 49 |
+
Keep the energy mellow but enthusiastic.""",
|
| 50 |
+
"voice_id": "ErXwobaYiN019PkySvjV", # "Antoni" - laid-back American
|
| 51 |
+
"emoji": "π",
|
| 52 |
+
},
|
| 53 |
+
"Yoda": {
|
| 54 |
+
"system_prompt": """You are Yoda, the wise Jedi Master, explaining things.
|
| 55 |
+
Use inverted sentence structure: object-subject-verb. "Strong with this one, the Force is."
|
| 56 |
+
Be wise, contemplative, and occasionally cryptic.
|
| 57 |
+
Compare concepts to the Force, the Jedi way, and the balance of things.
|
| 58 |
+
Use phrases like "Hmmmm", "Yes, yes", "Much to learn, you have."
|
| 59 |
+
Speak slowly and thoughtfully. Make profound observations.
|
| 60 |
+
Occasionally chuckle wisely: "Hehehehe".""",
|
| 61 |
+
"voice_id": "pqHfZKP75CvOlQylNhV4", # "Bill" - slow, thoughtful
|
| 62 |
+
"emoji": "π§",
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_persona_names() -> list[str]:
|
| 68 |
+
"""Return list of persona names for dropdown."""
|
| 69 |
+
return list(PERSONAS.keys())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_persona(name: str) -> dict:
|
| 73 |
+
"""Get persona config by name."""
|
| 74 |
+
return PERSONAS.get(name, PERSONAS["5-Year-Old"])
|
src/tts.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ElevenLabs Text-to-Speech integration."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from elevenlabs import ElevenLabs
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def get_client() -> ElevenLabs:
|
| 8 |
+
"""Get configured ElevenLabs client."""
|
| 9 |
+
api_key = os.getenv("ELEVENLABS_API_KEY")
|
| 10 |
+
if not api_key:
|
| 11 |
+
raise ValueError("ELEVENLABS_API_KEY environment variable not set")
|
| 12 |
+
return ElevenLabs(api_key=api_key)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def generate_speech(text: str, voice_id: str) -> bytes:
|
| 16 |
+
"""Generate speech audio from text.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
text: The text to convert to speech
|
| 20 |
+
voice_id: ElevenLabs voice ID
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
Audio bytes (MP3 format)
|
| 24 |
+
"""
|
| 25 |
+
client = get_client()
|
| 26 |
+
|
| 27 |
+
# Generate audio
|
| 28 |
+
audio_generator = client.text_to_speech.convert(
|
| 29 |
+
voice_id=voice_id,
|
| 30 |
+
text=text,
|
| 31 |
+
model_id="eleven_multilingual_v2",
|
| 32 |
+
output_format="mp3_44100_128",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Collect all audio chunks
|
| 36 |
+
audio_chunks = []
|
| 37 |
+
for chunk in audio_generator:
|
| 38 |
+
audio_chunks.append(chunk)
|
| 39 |
+
|
| 40 |
+
return b"".join(audio_chunks)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def generate_speech_file(text: str, voice_id: str, output_path: str) -> str:
|
| 44 |
+
"""Generate speech and save to file.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
text: The text to convert to speech
|
| 48 |
+
voice_id: ElevenLabs voice ID
|
| 49 |
+
output_path: Path to save the audio file
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Path to the saved audio file
|
| 53 |
+
"""
|
| 54 |
+
audio_bytes = generate_speech(text, voice_id)
|
| 55 |
+
|
| 56 |
+
with open(output_path, "wb") as f:
|
| 57 |
+
f.write(audio_bytes)
|
| 58 |
+
|
| 59 |
+
return output_path
|