Emperor555 Claude commited on
Commit
074e8ce
Β·
0 Parent(s):

Initial commit: Explainor - AI agent with persona voices

Browse files

MCP'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>

Files changed (10) hide show
  1. .env.example +2 -0
  2. .gitignore +9 -0
  3. README.md +126 -0
  4. app.py +268 -0
  5. modal_app.py +50 -0
  6. requirements.txt +6 -0
  7. src/__init__.py +15 -0
  8. src/agent.py +268 -0
  9. src/personas.py +74 -0
  10. 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
+ [![MCP Hackathon](https://img.shields.io/badge/MCP%20Hackathon-1st%20Birthday-purple)](https://huggingface.co/MCP-1st-Birthday)
6
+ [![Track](https://img.shields.io/badge/Track-MCP%20in%20Action-blue)](https://huggingface.co/MCP-1st-Birthday)
7
+ [![Category](https://img.shields.io/badge/Category-Creative-green)](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