open-notebook/.claude/sessions/oss-136/plan.md
Luis Novo d7b0fff954
Api podcast migration (#93)
Creates the API layer for Open Notebook
Creates a services API gateway for the Streamlit front-end
Migrates the SurrealDB SDK to the official one
Change all database calls to async
New podcast framework supporting multiple speaker configurations
Implement the surreal-commands library for async processing
Improve docker image and docker-compose configurations
2025-07-17 08:36:11 -03:00

1795 lines
No EOL
69 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# OSS-136 Epic: Podcast Engine + Background Infrastructure - Implementation Plan
## Overview
This plan breaks down the implementation of the new podcast engine and background infrastructure into manageable phases of approximately 3-4 hours each. Each phase is designed to be independent, testable, and builds upon the previous phase to create a competitive advantage against Google Notebook LM.
**Total Estimated Time**: 14-16 hours across 4 phases
**Risk Level**: Medium (new async architecture with proven libraries)
**Rollback Strategy**: Independent commits for each phase
**Dependencies**: surreal-commands, podcast-creator (both proven libraries)
**Strategic Goal**: Create 1-4 speaker flexibility vs Google's 2-host limitation with simplified Episode Profile workflow
---
## Phase 1: Async Foundation (OSS-137) - 4 hours
Surreal Commands Library: https://github.com/lfnovo/surreal-commands
Also available in Context7 and on /Users/luisnovo/dev/projetos/surreal-commands/surreal-commands
### 🎯 Goals
- Integrate surreal-commands for background job processing
- Create generic command infrastructure with example commands
- Set up worker process in existing container using supervisord
- Add Makefile command to start worker in dev environment
- Establish command-based architecture foundation for all future background processing
### 📁 Files to Create/Change
1. **NEW**: `commands/example_commands.py` - Generic command examples for testing (moved from /api/commands)
2. **NEW**: `commands/__init__.py` - Commands module initialization
3. **NEW**: `api/routers/commands.py` - Generic command execution endpoints
4. **NEW**: `api/command_service.py` - Generic service layer for command operations
5. **MODIFY**: `api/main.py` - Add commands router and import commands module
6. **MODIFY**: `supervisord.conf` - Add worker process
7. **MODIFY**: `pyproject.toml` - Add surreal-commands dependency
8. **MODIFY**: `Makefile` - Add worker start/stop/restart commands
9. **NEW**: `test_commands.sh` - Testing script for manual verification
### 🔧 Specific Implementation Steps
#### 1.1 Add Dependencies
```toml
# pyproject.toml - Add to dependencies array
dependencies = [
# ... existing dependencies
"surreal-commands>=1.0.7",
]
```
#### 1.2 Create Generic Command Infrastructure
```python
# commands/__init__.py
"""Surreal-commands integration for Open Notebook"""
# commands/example_commands.py
from surreal_commands import command
from pydantic import BaseModel
from typing import Optional, List
from loguru import logger
import asyncio
import time
class TextProcessingInput(BaseModel):
text: str
operation: str = "uppercase" # uppercase, lowercase, word_count, reverse
delay_seconds: Optional[int] = None # For testing async behavior
class TextProcessingOutput(BaseModel):
success: bool
original_text: str
processed_text: Optional[str] = None
word_count: Optional[int] = None
processing_time: float
error_message: Optional[str] = None
class DataAnalysisInput(BaseModel):
numbers: List[float]
analysis_type: str = "basic" # basic, detailed
delay_seconds: Optional[int] = None
class DataAnalysisOutput(BaseModel):
success: bool
analysis_type: str
count: int
sum: Optional[float] = None
average: Optional[float] = None
min_value: Optional[float] = None
max_value: Optional[float] = None
processing_time: float
error_message: Optional[str] = None
@command("process_text", app="open_notebook")
async def process_text_command(input_data: TextProcessingInput) -> TextProcessingOutput:
"""
Example command for text processing. Tests basic command functionality
and demonstrates different processing types.
"""
start_time = time.time()
try:
logger.info(f"Processing text with operation: {input_data.operation}")
# Simulate processing delay if specified
if input_data.delay_seconds:
await asyncio.sleep(input_data.delay_seconds)
processed_text = None
word_count = None
if input_data.operation == "uppercase":
processed_text = input_data.text.upper()
elif input_data.operation == "lowercase":
processed_text = input_data.text.lower()
elif input_data.operation == "reverse":
processed_text = input_data.text[::-1]
elif input_data.operation == "word_count":
word_count = len(input_data.text.split())
processed_text = f"Word count: {word_count}"
else:
raise ValueError(f"Unknown operation: {input_data.operation}")
processing_time = time.time() - start_time
return TextProcessingOutput(
success=True,
original_text=input_data.text,
processed_text=processed_text,
word_count=word_count,
processing_time=processing_time
)
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Text processing failed: {e}")
return TextProcessingOutput(
success=False,
original_text=input_data.text,
processing_time=processing_time,
error_message=str(e)
)
@command("analyze_data", app="open_notebook")
async def analyze_data_command(input_data: DataAnalysisInput) -> DataAnalysisOutput:
"""
Example command for data analysis. Tests command with complex input/output
and demonstrates error handling.
"""
start_time = time.time()
try:
logger.info(f"Analyzing {len(input_data.numbers)} numbers with {input_data.analysis_type} analysis")
# Simulate processing delay if specified
if input_data.delay_seconds:
await asyncio.sleep(input_data.delay_seconds)
if not input_data.numbers:
raise ValueError("No numbers provided for analysis")
count = len(input_data.numbers)
sum_value = sum(input_data.numbers)
average = sum_value / count
min_value = min(input_data.numbers)
max_value = max(input_data.numbers)
processing_time = time.time() - start_time
return DataAnalysisOutput(
success=True,
analysis_type=input_data.analysis_type,
count=count,
sum=sum_value,
average=average,
min_value=min_value,
max_value=max_value,
processing_time=processing_time
)
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Data analysis failed: {e}")
return DataAnalysisOutput(
success=False,
analysis_type=input_data.analysis_type,
count=0,
processing_time=processing_time,
error_message=str(e)
)
```
#### 1.3 Create Generic Command Service Layer
```python
# api/command_service.py
from typing import List, Optional, Dict, Any
from loguru import logger
from surreal_commands import submit_command, get_command_status
from api.models import ErrorResponse
class CommandService:
"""Generic service layer for command operations"""
@staticmethod
async def submit_command_job(
module_name: str,
command_name: str,
command_args: Dict[str, Any],
context: Optional[Dict[str, Any]] = None
) -> str:
"""Submit a generic command job for background processing"""
try:
cmd_id = submit_command(
module_name,
command_name,
command_args,
context=context
)
logger.info(f"Submitted command job: {cmd_id} for {module_name}.{command_name}")
return cmd_id
except Exception as e:
logger.error(f"Failed to submit command job: {e}")
raise
@staticmethod
async def get_command_status(job_id: str) -> Dict[str, Any]:
"""Get status of any command job"""
try:
status = await get_command_status(job_id)
return {
"job_id": job_id,
"status": status.status if status else "unknown",
"result": status.result if status else None,
"error_message": status.error_message if status else None,
"created": str(status.created) if status and status.created else None,
"updated": str(status.updated) if status and status.updated else None,
"progress": status.progress if status else None
}
except Exception as e:
logger.error(f"Failed to get command status: {e}")
raise
@staticmethod
async def list_command_jobs(
module_filter: Optional[str] = None,
command_filter: Optional[str] = None,
status_filter: Optional[str] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
"""List command jobs with optional filtering"""
# This will be implemented with proper SurrealDB queries
# For now, return empty list as this is foundation phase
return []
@staticmethod
async def cancel_command_job(job_id: str) -> bool:
"""Cancel a running command job"""
try:
# Implementation depends on surreal-commands cancellation support
# For now, just log the attempt
logger.info(f"Attempting to cancel job: {job_id}")
return True
except Exception as e:
logger.error(f"Failed to cancel command job: {e}")
raise
```
#### 1.4 Create Generic Command Endpoints
```python
# api/routers/commands.py
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from loguru import logger
from api.command_service import CommandService
from api.models import ErrorResponse
router = APIRouter()
class CommandExecutionRequest(BaseModel):
command: str = Field(..., description="Command function name (e.g., 'process_text')")
app: str = Field(..., description="Application name (e.g., 'open_notebook')")
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
class CommandJobResponse(BaseModel):
job_id: str
status: str
message: str
class CommandJobStatusResponse(BaseModel):
job_id: str
status: str
result: Optional[Dict[str, Any]] = None
error_message: Optional[str] = None
created: Optional[str] = None
updated: Optional[str] = None
progress: Optional[Dict[str, Any]] = None
@router.post("/commands/jobs", response_model=CommandJobResponse)
async def execute_command(request: CommandExecutionRequest):
"""
Submit a command for background processing.
Returns immediately with job ID for status tracking.
"""
# parameters
"command": "generate_podcast",
"app": "open_notebook",
"input": { "notebook_id": "123", "episode_profile": "tech" }
@router.get("/commands/{job_id}", response_model=CommandJobStatusResponse)
async def get_command_job_status(job_id: str):
"""Get the status of a specific command job"""
try:
status_data = await CommandService.get_command_status(job_id)
return CommandJobStatusResponse(**status_data)
except Exception as e:
logger.error(f"Error fetching job status: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch job status: {str(e)}"
)
@router.get("/commands/jobs", response_model=List[Dict[str, Any]])
async def list_command_jobs(
command_filter: Optional[str] = Query(None, description="Filter by command name"),
status_filter: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, description="Maximum number of jobs to return")
):
"""List command jobs with optional filtering"""
try:
jobs = await CommandService.list_command_jobs(
command_filter=command_filter,
status_filter=status_filter,
limit=limit
)
return jobs
except Exception as e:
logger.error(f"Error listing command jobs: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to list command jobs: {str(e)}"
)
@router.delete("/commands/jobs/{job_id}")
async def cancel_command_job(job_id: str):
"""Cancel a running command job"""
try:
success = await CommandService.cancel_command_job(job_id)
return {"job_id": job_id, "cancelled": success}
except Exception as e:
logger.error(f"Error cancelling command job: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to cancel command job: {str(e)}"
)
```
#### 1.5 Add Router to Main App
```python
# api/main.py - Add import and router
from api.routers import notebooks, search, models, transformations, notes, embedding, settings, context, sources, insights
from api.routers import commands as commands_router
# Import commands to register them in the API process
try:
import commands.example_commands
from loguru import logger
logger.info("Commands imported in API process")
except Exception as e:
from loguru import logger
logger.error(f"Failed to import commands in API process: {e}")
# Add to router includes (after line 31)
app.include_router(commands_router.router, prefix="/api", tags=["commands"])
```
#### 1.6 Configure Worker Process
```bash
# supervisord.conf - Add after [program:api] section
[program:worker]
command=uv run --env-file .env surreal-commands-worker --import-modules commands.example_commands
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
```
#### 1.7 Add Makefile Commands
```makefile
# Makefile - Add worker management commands
.PHONY: worker worker-start worker-stop worker-restart
worker: worker-start
worker-start:
@echo "Starting surreal-commands worker..."
uv run --env-file .env surreal-commands-worker --import-modules commands.example_commands
worker-stop:
@echo "Stopping surreal-commands worker..."
pkill -f "surreal-commands-worker" || true
worker-restart: worker-stop
@sleep 2
@$(MAKE) worker-start
```
### ✅ Testing Strategy
1. **Dependencies**: Verify surreal-commands installs correctly
2. **Worker Process**: Test worker starts and registers example commands successfully
3. **API Endpoints**: Test generic command submission and status retrieval
4. **Command Execution**: Verify example commands execute and return expected results
5. **Error Handling**: Test error scenarios and proper error responses
6. **Async Behavior**: Test commands with delays to verify non-blocking execution
### 🧪 Manual Testing Commands
```bash
# 1. Install dependencies
uv sync
# 2. Start SurrealDB
make database
# 3. Start API and worker separately for testing
# Terminal 1: Start API
make api
# Terminal 2: Start worker
make worker
# 4. Test example command endpoints (shortcuts)
curl -X POST "http://localhost:5055/api/commands/jobs" \
-H "Content-Type: application/json" \
-d '{
params
}
}'
# 6. Check job status (use job_id from responses)
curl "http://localhost:5055/api/commands/jobs/{job_id}"
# 7. List all command jobs
curl "http://localhost:5055/api/commands/jobs"
# 8. Test worker with supervisord (production mode)
docker compose up
# 9. Test Makefile commands
make worker-start
make worker-stop
make worker-restart
```
### ⚠️ Critical Notes
- **Worker Process**: Single worker only (surreal-commands current limitation)
- **Environment Setup**: Ensure SurrealDB is running before starting worker
- **Testing Required**: Thoroughly test async job submission and status tracking
- **🛑 STOP**: Request human approval before proceeding to Phase 2
---
## Phase 2: Engine Integration (OSS-138) - 4 hours
### 📚 Dependencies
- Surreal Commands Library: https://github.com/lfnovo/surreal-commands
- Available in Context7 and on /Users/luisnovo/dev/projetos/surreal-commands/surreal-commands
- Podcast Creator Library: https://github.com/lfnovo/podcast-creator
- Available in Context7 and on /Users/luisnovo/dev/projetos/podcast-creator/podcast-creator
### 🎯 Goals
- Integrate podcast-creator library with Episode Profiles
- Create domain models for Episode and Speaker profiles
- Implement real podcast generation with LangGraph workflow
- Replace placeholder implementation with production-ready engine
### 📁 Files to Create/Change
1. **NEW**: `open_notebook/domain/podcast.py` - Episode, Speaker, PodcastEpisode models
2. **NEW**: `api/routers/episode_profiles.py` - Episode profile management endpoints
3. **NEW**: `api/routers/speaker_profiles.py` - Speaker profile management endpoints
4. **MODIFY**: `commands/podcast_commands.py` - Real podcast generation implementation
5. **MODIFY**: `api/main.py` - Add new routers
6. **DELETE AT THE END**: `plugins/podcasts.py` - Old Podcast module that we are replacing
### 🔧 Before you start
Database models have already been created
Referer to the file 7.surrealql to see that has already been created.
### 🔧 Specific Implementation Steps
#### 2.1 Create Domain Models
```python
# open_notebook/domain/podcast.py
from typing import ClassVar, Optional, List, Dict, Any
from pydantic import Field, validator
from open_notebook.domain.base import ObjectModel
class EpisodeProfile(ObjectModel):
"""
Episode Profile - Simplified podcast configuration.
Replaces complex 15+ field configuration with user-friendly profiles.
"""
table_name: ClassVar[str] = "episode_profile"
name: str = Field(..., description="Unique profile name")
description: Optional[str] = Field(None, description="Profile description")
speaker_config: str = Field(..., description="Reference to speaker profile name")
outline_provider: str = Field(..., description="AI provider for outline generation")
outline_model: str = Field(..., description="AI model for outline generation")
transcript_provider: str = Field(..., description="AI provider for transcript generation")
transcript_model: str = Field(..., description="AI model for transcript generation")
default_briefing: str = Field(..., description="Default briefing template")
num_segments: int = Field(default=5, description="Number of podcast segments")
@validator('num_segments')
def validate_segments(cls, v):
if not 3 <= v <= 20:
raise ValueError('Number of segments must be between 3 and 20')
return v
@classmethod
async def get_by_name(cls, name: str) -> Optional['EpisodeProfile']:
"""Get episode profile by name"""
from open_notebook.database.repository import repo_query, ensure_record_id
result = await repo_query(
"SELECT * FROM episode_profile WHERE name = $name",
{"name": name}
)
if result:
return cls(**result[0])
return None
class SpeakerProfile(ObjectModel):
"""
Speaker Profile - Voice and personality configuration.
Supports 1-4 speakers for flexible podcast formats.
"""
table_name: ClassVar[str] = "speaker_profile"
name: str = Field(..., description="Unique profile name")
description: Optional[str] = Field(None, description="Profile description")
tts_provider: str = Field(..., description="TTS provider (openai, elevenlabs, etc.)")
tts_model: str = Field(..., description="TTS model name")
speakers: List[Dict[str, Any]] = Field(..., description="Array of speaker configurations")
@validator('speakers')
def validate_speakers(cls, v):
if not 1 <= len(v) <= 4:
raise ValueError('Must have between 1 and 4 speakers')
required_fields = ['name', 'voice_id', 'backstory', 'personality']
for speaker in v:
for field in required_fields:
if field not in speaker:
raise ValueError(f'Speaker missing required field: {field}')
return v
@classmethod
async def get_by_name(cls, name: str) -> Optional['SpeakerProfile']:
"""Get speaker profile by name"""
from open_notebook.database.repository import repo_query
result = await repo_query(
"SELECT * FROM speaker_profile WHERE name = $name",
{"name": name}
)
if result:
return cls(**result[0])
return None
from surrealdb import RecordID
class PodcastEpisode(ObjectModel):
"""Enhanced PodcastEpisode with job tracking and metadata"""
table_name: ClassVar[str] = "episode"
name: str
episode_profile: str = Field(..., description="Episode profile used")
generation_metadata: Dict[str, Any] = Field(default_factory=dict, description="Generation parameters")
briefing: str = Field(..., description="Full briefing used for generation")
text: str = Field(..., description="Source content")
audio_file: Optional[str] = Field(None, description="Path to generated audio file")
transcript_file: Optional[str] = Field(None, description="Path to transcript file")
outline_file: Optional[str] = Field(None, description="Path to outline file")
command: Optional[Union[str, RecordID]] = Field(None, description="Link to surreal-commands job")
async def get_job_status(self) -> Optional[str]:
"""Get the status of the associated command"""
if not self.command:
return None
from surreal_commands import get_command_status
try:
status = await get_command_status(self.command)
return status.status if status else "unknown"
except Exception:
return "unknown"
```
#### 2.2 - Load the episode_profile and speaker_profile objects from SurrealDB into podcast-creator using its configure methods and Create the command
Look for a reference on commands/example_commands.py or look in the surreal-commands documentation for more details on how to create a command
Your command will get the speaker_profile, episode_profile, episode_name, additional_briefing and content as input and will generate the podcast episode
set output_dir as os.environ.get("DATA_DIR", "/podcasts")
The command will call the generate_podcast method from podcast_creator with the following parameters:
- output_dir
- episode_profile
- episode_name
- additional_briefing
- content
```python
# commands/podcast_commands.py
from podcast_creator import configure
# get the profiles
episode_profiles = await repo_query("select * from episode_profile")
speaker_profiles = await repo_query("select * from speaker_profile")
# transform the surrealdb array into a dictionary so you can pass them to config like this:
episode_profiles_dict = {profile["name"]: profile for profile in episode_profiles}
speaker_profiles_dict = {profile["name"]: profile for profile in speaker_profiles}
# Define custom episode profiles
configure("episode_config", {
"profiles": episode_profiles_dict
})
configure("speaker_config", {
"profiles": speaker_profiles_dict
})
# commands/podcast_commands.py - Replace placeholder with real implementation
from podcast_creator import create_podcast, configure
from open_notebook.domain.podcast import EpisodeProfile, SpeakerProfile, PodcastEpisode
from open_notebook.domain.notebook import Notebook
from pathlib import Path
import json
@command("generate_podcast")
async def generate_podcast_command(
input_data: PodcastGenerationInput
) -> PodcastGenerationOutput:
"""
Real podcast generation using podcast-creator library with Episode Profiles
"""
try:
logger.info(f"Starting podcast generation for episode: {input_data.episode_name}")
# 1. Load Episode and Speaker profiles
episode_profile = await EpisodeProfile.get_by_name(input_data.episode_profile_name)
speaker_profile = await SpeakerProfile.get_by_name(episode_profile.speaker_config)
# 4. Generate briefing
briefing = episode_profile.default_briefing
if input_data.briefing_suffix:
briefing += f"\n\nAdditional instructions: {input_data.briefing_suffix}"
# 5. Create output directory
output_dir = Path(f"{os.environ.get('DATA_DIR', '/podcasts')}/episodes/{input_data.episode_name}")
output_dir.mkdir(parents=True, exist_ok=True)
# 6. Generate podcast using podcast-creator
result = await create_podcast(
content=input_data.content,
briefing=briefing,
episode_name=input_data.episode_name,
output_dir=str(output_dir),
speaker_profile=speaker_profile.name,
podcast_profile=episode_profile.name,
)
# 7. Save episode record
episode = PodcastEpisode(
name=input_data.episode_name,
episode_profile=episode_profile.model_dump(),
speaker_profile=speaker_profile.model_dump(),
briefing=briefing,
content=str(context),
audio_file=result.get("final_output_file_path"),
transcript=result.get("transcript"),
outline=result.get("outline")
)
await episode.save()
logger.info(f"Successfully generated podcast episode: {episode.id}")
return PodcastGenerationOutput(
success=True,
episode_id=str(episode.id),
audio_file_path=result.get("final_output_file_path"),
)
except Exception as e:
logger.error(f"Podcast generation failed: {e}")
return PodcastGenerationOutput(
success=False,
error_message=str(e)
)
```
#### 2.3 - Create the API endpoint for podcast generation and the esrvice that will service the API and submit the command to surreal-commands
POST /podcast/episode
#### 2.4 Create Profile Management Endpoints
```python
# api/routers/episode_profiles.py
from typing import List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from open_notebook.domain.podcast import EpisodeProfile
from api.models import ErrorResponse
router = APIRouter()
class EpisodeProfileResponse(BaseModel):
id: str
name: str
description: str
speaker_config: str
outline_provider: str
outline_model: str
transcript_provider: str
transcript_model: str
default_briefing: str
num_segments: int
@router.get("/episode-profiles", response_model=List[EpisodeProfileResponse])
async def list_episode_profiles():
"""List all available episode profiles"""
try:
profiles = await EpisodeProfile.get_all(order_by="name asc")
return [
EpisodeProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description or "",
speaker_config=profile.speaker_config,
outline_provider=profile.outline_provider,
outline_model=profile.outline_model,
transcript_provider=profile.transcript_provider,
transcript_model=profile.transcript_model,
default_briefing=profile.default_briefing,
num_segments=profile.num_segments
)
for profile in profiles
]
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch episode profiles: {str(e)}"
)
# api/routers/speaker_profiles.py
from typing import List, Dict, Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from open_notebook.domain.podcast import SpeakerProfile
router = APIRouter()
class SpeakerProfileResponse(BaseModel):
id: str
name: str
description: str
tts_provider: str
tts_model: str
speakers: List[Dict[str, Any]]
@router.get("/speaker-profiles", response_model=List[SpeakerProfileResponse])
async def list_speaker_profiles():
"""List all available speaker profiles"""
try:
profiles = await SpeakerProfile.get_all(order_by="name asc")
return [
SpeakerProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description or "",
tts_provider=profile.tts_provider,
tts_model=profile.tts_model,
speakers=profile.speakers
)
for profile in profiles
]
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to fetch speaker profiles: {str(e)}"
)
```
### ✅ Testing Strategy
1. **Profile Management**: Test episode and speaker profile CRUD operations
2. **Real Generation**: Test end-to-end podcast generation through the API -> surreal-commands -> podcast-creator
3. **Error Handling**: Test various failure scenarios (missing profiles, invalid content)
4. **Integration**: Verify podcast-creator integration with Episode Profiles
### 🧪 Manual Testing Commands
```bash
# 2. List available profiles
curl "http://localhost:5055/api/episode-profiles"
curl "http://localhost:5055/api/speaker-profiles"
# 3. Generate real podcast
curl -X POST "http://localhost:5055/api/podcasts/episodes" \
-H "Content-Type: application/json" \
-d '{
"episode_profile_name": "tech_discussion",
"content": "My first episode",
"episode_name": "my_first_episode"
"briefing_suffix": "Additional instructions blabla"
"speaker_profile_name": "tech_experts"
}'
# 4. Monitor job progress
curl "http://localhost:5055/api/commands/jobs/{job_id}"
```
### ⚠️ Critical Notes
- **Real Audio Generation**: This phase produces actual podcast audio files (~2-3 minutes)
- **Error Recovery**: Implement proper cleanup on generation failure
- **🛑 STOP**: Request human approval before proceeding to Phase 3
---
## Phase 3: UI Modernization (OSS-139) - 3 hours
### 🎯 Goals
- Simplify Streamlit UI from 15+ fields to 3-click workflow (Profile → Name → Generate)
- Display podcast episodes with job status via database relationships
- Implement non-blocking podcast generation UX
- Prepare UI foundation for future React migration
### 📁 Files to Create/Change
1. **MODIFY**: `pages/5_🎙_Podcasts.py` - Complete UI overhaul (make a backup before starting)
2. **NEW**: `pages/components/episode_profile_selector.py` - Profile selection component
3. **NEW**: `pages/components/podcast_status_display.py` - Status display component
4. **MODIFY**: `pages/stream_app/chat.py` - Update podcast tab integration
### 🔧 Specific Implementation Steps
#### 3.1 Create Profile Selection Component
```python
# pages/components/episode_profile_selector.py
import asyncio
import streamlit as st
from typing import List, Optional
from open_notebook.domain.podcast import EpisodeProfile, SpeakerProfile
class EpisodeProfileSelector:
"""Component for selecting episode profiles with preview"""
@staticmethod
async def render() -> Optional[str]:
"""Render episode profile selector and return selected profile name"""
# Load available profiles
profiles = asyncio.run(EpisodeProfile.get_all(order_by="name asc"))
if not profiles:
st.error("No episode profiles available. Please contact administrator.")
return None
# Create profile options with descriptions
profile_options = {}
for profile in profiles:
display_name = f"{profile.name} - {profile.description}" if profile.description else profile.name
profile_options[display_name] = profile.name
# Profile selection
selected_display = st.selectbox(
"Choose Episode Profile",
options=list(profile_options.keys()),
help="Select a pre-configured podcast style"
)
if selected_display:
selected_name = profile_options[selected_display]
selected_profile = next(p for p in profiles if p.name == selected_name)
# Show profile preview
with st.expander("📝 Profile Details", expanded=False):
st.write(f"**Description:** {selected_profile.description or 'No description'}")
st.write(f"**Speaker Configuration:** {selected_profile.speaker_config}")
st.write(f"**Segments:** {selected_profile.num_segments}")
st.write(f"**AI Models:** {selected_profile.outline_provider}/{selected_profile.outline_model} (outline), {selected_profile.transcript_provider}/{selected_profile.transcript_model} (transcript)")
# Show speaker preview
speaker_profile = asyncio.run(SpeakerProfile.get_by_name(selected_profile.speaker_config))
if speaker_profile:
st.write(f"**Speakers ({len(speaker_profile.speakers)}):**")
for speaker in speaker_profile.speakers:
st.write(f"- **{speaker['name']}**: {speaker['personality']}")
with st.container():
st.text_area(
"Default Briefing:",
value=selected_profile.default_briefing,
height=100,
disabled=True
)
return selected_name
return None
```
#### 3.2 Create Status Display Component
```python
# pages/components/podcast_status_display.py
import asyncio
import streamlit as st
from typing import List
from datetime import datetime
from open_notebook.domain.podcast import PodcastEpisode
import humanize
class PodcastStatusDisplay:
"""Component for displaying podcast episodes with job status"""
@staticmethod
async def render(notebook_id: Optional[str] = None):
"""Render podcast episodes with status"""
# Get episodes with job status
episodes = await PodcastStatusDisplay._get_episodes_with_status(notebook_id)
if not episodes:
st.info("No podcast episodes found. Generate your first episode above!")
return
st.subheader(f"📻 Podcast Episodes ({len(episodes)})")
# Group by status for better organization
status_groups = {
"completed": [],
"running": [],
"failed": [],
"pending": []
}
for episode in episodes:
status = episode.get("job_status", "unknown")
if status == "completed":
status_groups["completed"].append(episode)
elif status in ["running", "processing"]:
status_groups["running"].append(episode)
elif status == "failed":
status_groups["failed"].append(episode)
else:
status_groups["pending"].append(episode)
# Display running jobs first
if status_groups["running"]:
st.write("🔄 **Currently Processing**")
for episode in status_groups["running"]:
PodcastStatusDisplay._render_episode_card(episode, show_audio=False)
# Display completed episodes
if status_groups["completed"]:
st.write("✅ **Completed Episodes**")
for episode in status_groups["completed"]:
PodcastStatusDisplay._render_episode_card(episode, show_audio=True)
# Display failed jobs
if status_groups["failed"]:
st.write("❌ **Failed Episodes**")
for episode in status_groups["failed"]:
PodcastStatusDisplay._render_episode_card(episode, show_audio=False)
# Display pending jobs
if status_groups["pending"]:
st.write("⏳ **Pending Episodes**")
for episode in status_groups["pending"]:
PodcastStatusDisplay._render_episode_card(episode, show_audio=False)
@staticmethod
def _render_episode_card(episode_data: dict, show_audio: bool = True):
"""Render individual episode card"""
with st.container():
st.markdown("---")
col1, col2, col3 = st.columns([3, 1, 1])
with col1:
status_emoji = {
"completed": "✅",
"running": "🔄",
"failed": "❌",
"pending": "⏳"
}.get(episode_data.get("job_status", "unknown"), "❓")
st.write(f"{status_emoji} **{episode_data['name']}**")
st.caption(f"Profile: {episode_data.get('episode_profile', 'Unknown')}")
with col2:
if episode_data.get("created"):
created_date = datetime.fromisoformat(episode_data["created"].replace('Z', '+00:00'))
st.caption(f"Created: {humanize.naturaltime(created_date)}")
with col3:
# Refresh button for non-completed episodes
if episode_data.get("job_status") not in ["completed", "failed"]:
if st.button("🔄", key=f"refresh_{episode_data['id']}", help="Refresh status"):
st.rerun()
# Show audio player for completed episodes
if show_audio and episode_data.get("audio_file"):
try:
st.audio(episode_data["audio_file"], format="audio/mpeg")
except Exception:
st.error("Audio file not found or corrupted")
# Show error message for failed episodes
if episode_data.get("job_status") == "failed" and episode_data.get("error_message"):
st.error(f"Error: {episode_data['error_message']}")
# Show metadata in expander
with st.expander(f"Details - {episode_data['name']}", expanded=False):
metadata = episode_data.get("generation_metadata", {})
if metadata:
st.json(metadata)
if episode_data.get("briefing"):
st.text_area(
"Briefing Used:",
value=episode_data["briefing"],
height=100,
disabled=True,
key=f"briefing_{episode_data['id']}"
)
@staticmethod
async def _get_episodes_with_status(notebook_id: Optional[str] = None) -> List[dict]:
"""Get episodes with their job status"""
from open_notebook.database.repository import repo_query
# Query episodes with command status
if notebook_id:
query = """
SELECT *,
command.status AS job_status,
command.error_message AS error_message
FROM podcast_episode
WHERE notebook_id = $notebook_id
ORDER BY created DESC
"""
params = {"notebook_id": notebook_id}
else:
query = """
SELECT *,
command.status AS job_status,
command.error_message AS error_message
FROM podcast_episode
ORDER BY created DESC
"""
params = {}
result = await repo_query(query, params)
return result
```
#### 3.3 Modernize Main Podcast Page
```python
# pages/5_🎙_Podcasts.py - Complete rewrite
import asyncio
import streamlit as st
import nest_asyncio
from pages.stream_app.utils import setup_page
from pages.components.episode_profile_selector import EpisodeProfileSelector
from pages.components.podcast_status_display import PodcastStatusDisplay
from api.podcast_service import PodcastService, DefaultProfiles
nest_asyncio.apply()
setup_page("🎙️ Podcasts", only_check_mandatory_models=False)
# Page title and description
st.title("🎙️ Podcast Generator")
st.markdown("""
Create professional podcasts from your notebook content using AI-powered Episode Profiles.
Choose from pre-configured styles or create custom profiles for your unique podcast format.
""")
# Initialize default profiles if needed
if st.button("🔧 Initialize Default Profiles", help="Create default episode and speaker profiles"):
with st.spinner("Creating default profiles..."):
try:
asyncio.run(DefaultProfiles.create_default_episode_profiles())
asyncio.run(DefaultProfiles.create_default_speaker_profiles())
st.success("✅ Default profiles created successfully!")
except Exception as e:
st.error(f"Failed to create default profiles: {e}")
st.markdown("---")
# Main podcast generation section
st.subheader("🎬 Generate New Episode")
# Check if we have a current notebook
current_notebook_id = st.session_state.get("current_notebook_id")
if not current_notebook_id:
st.warning("⚠️ Please select a notebook first from the main page.")
st.stop()
col1, col2 = st.columns([2, 1])
with col1:
# Episode Profile Selection (3-click workflow starts here)
selected_profile = asyncio.run(EpisodeProfileSelector.render())
if selected_profile:
# Episode Name Input
episode_name = st.text_input(
"Episode Name",
placeholder="e.g., Tech Discussion on AI Trends",
help="Choose a descriptive name for your podcast episode"
)
# Optional briefing suffix
briefing_suffix = st.text_area(
"Additional Instructions (Optional)",
placeholder="Add specific instructions for this episode...",
height=100,
help="Customize the briefing for this specific episode"
)
with col2:
st.markdown("### 📋 Generation Checklist")
st.markdown(f"""
- {'✅' if selected_profile else '⏳'} **Episode Profile**: {selected_profile or 'Not selected'}
- {'✅' if episode_name else '⏳'} **Episode Name**: {'Set' if episode_name else 'Required'}
- {'✅' if current_notebook_id else '❌'} **Notebook Content**: {'Available' if current_notebook_id else 'Missing'}
""")
# Generate button (3-click workflow completion)
if selected_profile and episode_name and current_notebook_id:
st.markdown("---")
# Estimated generation time
st.info("⏱️ **Estimated generation time**: 2-3 minutes for professional quality podcast")
if st.button("🚀 Generate Podcast", type="primary", use_container_width=True):
with st.spinner("🎙️ Starting podcast generation..."):
try:
job_id = asyncio.run(PodcastService.submit_generation_job(
notebook_id=current_notebook_id,
episode_profile_name=selected_profile,
episode_name=episode_name,
briefing_suffix=briefing_suffix if briefing_suffix.strip() else None
))
st.success(f"""
✅ **Podcast generation started!**
**Job ID**: `{job_id}`
Your podcast is being generated in the background. You can continue using Open Notebook while it processes.
The episode will appear in the list below when completed.
""")
# Auto-refresh to show the new job
st.rerun()
except Exception as e:
st.error(f"❌ Failed to start podcast generation: {e}")
st.markdown("---")
# Episodes display section
asyncio.run(PodcastStatusDisplay.render(current_notebook_id))
# Footer with helpful information
st.markdown("---")
with st.expander(" How it works", expanded=False):
st.markdown("""
### 🎯 3-Click Podcast Generation
1. **Choose Profile**: Select from pre-configured episode styles
2. **Name Episode**: Give your podcast a descriptive name
3. **Generate**: Click generate and continue using Open Notebook
### 🎨 Episode Profiles
- **Tech Discussion**: 2 experts discussing technical topics
- **Solo Expert**: Single expert explaining complex concepts
- **Business Analysis**: Business-focused panel discussion
### 🔄 Background Processing
- Podcasts generate in the background (2-3 minutes)
- No need to wait - continue your research
- Refresh the page to see updates
### 🎧 Professional Quality
- Multiple AI models for outline and transcript generation
- High-quality text-to-speech with personality-rich speakers
- Support for 1-4 speakers (vs Google's 2-speaker limit)
""")
```
#### 3.4 Update Chat Integration
```python
# pages/stream_app/chat.py - Update podcast tab (lines 76-132)
with podcast_tab:
st.markdown("### 🎙️ Quick Podcast Generation")
# Simple profile selector for chat context
episode_profiles = asyncio.run(EpisodeProfile.get_all())
if episode_profiles:
profile_names = [ep.name for ep in episode_profiles]
selected_template = st.selectbox("Episode Profile", profile_names)
episode_name = st.text_input("Episode Name", key="chat_episode_name")
if episode_name and selected_template:
if st.button("🚀 Generate from Chat Context"):
try:
job_id = asyncio.run(PodcastService.submit_generation_job(
notebook_id=current_notebook.id,
episode_profile_name=selected_template,
episode_name=episode_name,
briefing_suffix="Generate podcast from current chat context"
))
st.success(f"Podcast generation started! Job ID: {job_id}")
except Exception as e:
st.error(f"Failed to generate podcast: {e}")
else:
st.warning("No episode profiles available. Please initialize default profiles.")
st.page_link("pages/5_🎙_Podcasts.py", label="🎙️ Go to Full Podcast Interface")
```
### ✅ Testing Strategy
1. **Profile Selection**: Test episode profile selection and preview
2. **3-Click Workflow**: Verify simplified generation process
3. **Status Display**: Test job status updates and refresh functionality
4. **Audio Playback**: Verify completed episodes play correctly
5. **Error Handling**: Test UI behavior with failed generations
6. **Chat Integration**: Test quick generation from chat context
### 🧪 Manual Testing Scenarios
```
# Test 3-Click Workflow:
1. Navigate to Podcasts page
2. Select "tech_discussion" profile
3. Enter episode name "Test Episode"
4. Click "Generate Podcast"
5. Verify job appears in status list
6. Wait for completion and test audio playback
# Test Status Updates:
1. Generate multiple episodes
2. Refresh page to see status updates
3. Test failed episode error display
4. Verify completed episodes show audio player
# Test Profile Management:
1. Initialize default profiles
2. Verify all profiles load correctly
3. Test profile preview information
4. Verify speaker configuration display
```
### ⚠️ Critical Notes
- **UI Simplification**: Massive reduction from 15+ fields to 3 clicks
- **Non-blocking UX**: Users can continue working while podcasts generate
- **No Real-time Updates**: Simple refresh-based status (preparing for React migration)
- **Profile Dependencies**: Ensure default profiles are created before first use
- **Audio Storage**: Verify audio files are accessible from Streamlit
- **🛑 STOP**: Request human approval before proceeding to Phase 4
---
## Phase 4: Data Migration (OSS-141) - 3 hours
### 🎯 Goals
- Create new database schema for Episode and Speaker profiles
- Migrate existing podcast_config data to new Episode Profile format
- Maintain backward compatibility during transition
- Enable smooth rollback if needed
### 📁 Files to Create/Change
1. **NEW**: `migrations/7.surrealql` - New schema creation
2. **NEW**: `migrations/7_down.surrealql` - Rollback script
3. **NEW**: `api/migration_service.py` - Data migration utilities
4. **NEW**: `api/routers/migration.py` - Migration management endpoints
5. **MODIFY**: `api/main.py` - Add migration router
### 🔧 Specific Implementation Steps
#### 4.1 Create New Database Schema
```sql
-- migrations/7.surrealql
DEFINE TABLE IF NOT EXISTS episode_profile SCHEMAFULL;
DEFINE FIELD IF NOT EXISTS name ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS description ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD IF NOT EXISTS speaker_config ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS outline_provider ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS outline_model ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS transcript_provider ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS transcript_model ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS default_briefing ON TABLE episode_profile TYPE string;
DEFINE FIELD IF NOT EXISTS num_segments ON TABLE episode_profile TYPE int DEFAULT 5;
DEFINE FIELD IF NOT EXISTS migrated_from_podcast_config ON TABLE episode_profile TYPE option<string>;
DEFINE FIELD IF NOT EXISTS created ON TABLE episode_profile TYPE datetime DEFAULT time::now();
DEFINE FIELD IF NOT EXISTS updated ON TABLE episode_profile TYPE datetime DEFAULT time::now();
-- Create Speaker Profile table
DEFINE TABLE IF NOT EXISTS speaker_profile SCHEMAFULL;
DEFINE FIELD IF NOT EXISTS name ON TABLE speaker_profile TYPE string;
DEFINE FIELD IF NOT EXISTS description ON TABLE speaker_profile TYPE option<string>;
DEFINE FIELD IF NOT EXISTS tts_provider ON TABLE speaker_profile TYPE string;
DEFINE FIELD IF NOT EXISTS tts_model ON TABLE speaker_profile TYPE string;
DEFINE FIELD IF NOT EXISTS speakers ON TABLE speaker_profile TYPE array;
DEFINE FIELD IF NOT EXISTS migrated_from_podcast_config ON TABLE speaker_profile TYPE option<string>;
DEFINE FIELD IF NOT EXISTS created ON TABLE speaker_profile TYPE datetime DEFAULT time::now();
DEFINE FIELD IF NOT EXISTS updated ON TABLE speaker_profile TYPE datetime DEFAULT time::now();
-- Enhance PodcastEpisode table
DEFINE TABLE IF NOT EXISTS episode SCHEMAFULL;
DEFINE FIELD IF NOT EXISTS episode_profile ON TABLE episode TYPE string;
DEFINE FIELD IF NOT EXISTS generation_metadata ON TABLE episode TYPE object;
DEFINE FIELD IF NOT EXISTS briefing ON TABLE episode TYPE option<string>;
DEFINE FIELD IF NOT EXISTS transcript ON TABLE episode TYPE option<object>;
DEFINE FIELD IF NOT EXISTS outline ON TABLE episode TYPE option<object>;
DEFINE FIELD IF NOT EXISTS command ON TABLE episode TYPE record<command>;
-- Create indexes for better performance
DEFINE INDEX IF NOT EXISTS idx_episode_profile_name ON TABLE episode_profile COLUMNS name UNIQUE CONCURRENTLY;
DEFINE INDEX IF NOT EXISTS idx_speaker_profile_name ON TABLE speaker_profile COLUMNS name UNIQUE CONCURRENTLY;
DEFINE INDEX IF NOT EXISTS idx_episode_profile ON TABLE episode COLUMNS episode_profile CONCURRENTLY;
DEFINE INDEX IF NOT EXISTS idx_episode_command ON TABLE episode COLUMNS command CONCURRENTLY;
```
#### 4.3 Create Migration Service
```python
# api/migration_service.py
from typing import List, Dict, Any, Optional
from loguru import logger
from open_notebook.database.repository import repo_query, repo_create
from open_notebook.domain.podcast import EpisodeProfile, SpeakerProfile
class PodcastMigrationService:
"""Service for migrating podcast_config data to Episode Profiles"""
@staticmethod
async def analyze_existing_configs() -> Dict[str, Any]:
"""Analyze existing podcast_config records for migration planning"""
try:
configs = await repo_query("SELECT * FROM podcast_config")
analysis = {
"total_configs": len(configs),
"unique_combinations": {},
"tts_providers": set(),
"models": set(),
"languages": set(),
"migration_candidates": []
}
for config in configs:
# Analyze TTS usage
analysis["tts_providers"].add(config.get("provider", "unknown"))
analysis["models"].add(config.get("model", "unknown"))
analysis["languages"].add(config.get("output_language", "unknown"))
# Create combination signature for deduplication
combo_key = f"{config.get('provider')}_{config.get('model')}_{len(config.get('person1_role', []))}_{len(config.get('person2_role', []))}"
if combo_key not in analysis["unique_combinations"]:
analysis["unique_combinations"][combo_key] = {
"count": 0,
"example_config": config,
"suggested_profile_name": PodcastMigrationService._suggest_profile_name(config)
}
analysis["unique_combinations"][combo_key]["count"] += 1
# Add to migration candidates
analysis["migration_candidates"].append({
"config_id": config.get("id"),
"name": config.get("name"),
"suggested_episode_profile": PodcastMigrationService._suggest_profile_name(config),
"suggested_speaker_profile": PodcastMigrationService._suggest_speaker_profile_name(config)
})
# Convert sets to lists for JSON serialization
analysis["tts_providers"] = list(analysis["tts_providers"])
analysis["models"] = list(analysis["models"])
analysis["languages"] = list(analysis["languages"])
return analysis
except Exception as e:
logger.error(f"Failed to analyze existing configs: {e}")
raise
@staticmethod
def _suggest_profile_name(config: Dict[str, Any]) -> str:
"""Suggest an episode profile name based on config characteristics"""
person1_roles = config.get("person1_role", [])
person2_roles = config.get("person2_role", [])
# Determine if it's solo or multi-speaker
if not person2_roles or len(person2_roles) == 0:
return f"solo_{config.get('name', 'custom').lower().replace(' ', '_')}"
# Look for common role patterns
all_roles = person1_roles + person2_roles
if any("tech" in role.lower() or "engineer" in role.lower() for role in all_roles):
return f"tech_{config.get('name', 'discussion').lower().replace(' ', '_')}"
elif any("business" in role.lower() or "analyst" in role.lower() for role in all_roles):
return f"business_{config.get('name', 'analysis').lower().replace(' ', '_')}"
else:
return f"custom_{config.get('name', 'discussion').lower().replace(' ', '_')}"
@staticmethod
def _suggest_speaker_profile_name(config: Dict[str, Any]) -> str:
"""Suggest a speaker profile name based on config characteristics"""
provider = config.get("provider", "openai")
person2_roles = config.get("person2_role", [])
if not person2_roles or len(person2_roles) == 0:
return f"solo_{provider}"
else:
return f"dual_{provider}"
@staticmethod
async def migrate_config_to_profiles(config_id: str) -> Dict[str, str]:
"""Migrate a specific podcast_config to Episode and Speaker profiles"""
try:
# Get the config
config_result = await repo_query(
"SELECT * FROM podcast_config WHERE id = $id",
{"id": config_id}
)
if not config_result:
raise ValueError(f"Config not found: {config_id}")
config = config_result[0]
# Create speaker profile
speaker_profile_name = PodcastMigrationService._suggest_speaker_profile_name(config)
speakers = []
# Add first speaker
if config.get("person1_role"):
speakers.append({
"name": "Speaker 1",
"voice_id": config.get("voice1", "nova"),
"backstory": f"Expert in: {', '.join(config.get('person1_role', []))}",
"personality": f"Role: {', '.join(config.get('person1_role', []))}"
})
# Add second speaker if exists
if config.get("person2_role") and len(config.get("person2_role", [])) > 0:
speakers.append({
"name": "Speaker 2",
"voice_id": config.get("voice2", "alloy"),
"backstory": f"Expert in: {', '.join(config.get('person2_role', []))}",
"personality": f"Role: {', '.join(config.get('person2_role', []))}"
})
# Check if speaker profile already exists
existing_speaker = await SpeakerProfile.get_by_name(speaker_profile_name)
if not existing_speaker:
speaker_profile = SpeakerProfile(
name=speaker_profile_name,
description=f"Migrated from podcast_config: {config.get('name')}",
tts_provider=config.get("provider", "openai"),
tts_model=config.get("model", "tts-1"),
speakers=speakers,
migrated_from_podcast_config=config_id
)
await speaker_profile.save()
# Create episode profile
episode_profile_name = PodcastMigrationService._suggest_profile_name(config)
# Build briefing from old fields
briefing_parts = [
f"Podcast: {config.get('podcast_name', 'Unknown')}",
f"Tagline: {config.get('podcast_tagline', '')}",
f"Language: {config.get('output_language', 'English')}",
]
if config.get("conversation_style"):
briefing_parts.append(f"Conversation Style: {', '.join(config.get('conversation_style', []))}")
if config.get("engagement_technique"):
briefing_parts.append(f"Engagement Techniques: {', '.join(config.get('engagement_technique', []))}")
if config.get("user_instructions"):
briefing_parts.append(f"Special Instructions: {config.get('user_instructions')}")
default_briefing = "\n".join(briefing_parts)
# Determine number of segments from dialogue_structure
num_segments = len(config.get("dialogue_structure", [])) if config.get("dialogue_structure") else 5
num_segments = max(3, min(10, num_segments)) # Clamp between 3-10
# Check if episode profile already exists
existing_episode = await EpisodeProfile.get_by_name(episode_profile_name)
if not existing_episode:
episode_profile = EpisodeProfile(
name=episode_profile_name,
description=f"Migrated from podcast_config: {config.get('name')}",
speaker_config=speaker_profile_name,
outline_provider=config.get("transcript_model_provider", "openai"),
outline_model=config.get("transcript_model", "gpt-4o-mini"),
transcript_provider=config.get("transcript_model_provider", "openai"),
transcript_model=config.get("transcript_model", "gpt-4o-mini"),
default_briefing=default_briefing,
num_segments=num_segments,
migrated_from_podcast_config=config_id
)
await episode_profile.save()
return {
"episode_profile": episode_profile_name,
"speaker_profile": speaker_profile_name
}
except Exception as e:
logger.error(f"Failed to migrate config {config_id}: {e}")
raise
@staticmethod
async def migrate_all_configs() -> Dict[str, Any]:
"""Migrate all podcast_config records to new format"""
try:
configs = await repo_query("SELECT * FROM podcast_config")
results = {
"total_configs": len(configs),
"migrated": 0,
"failed": 0,
"skipped": 0,
"episode_profiles_created": set(),
"speaker_profiles_created": set(),
"errors": []
}
for config in configs:
try:
# Check if already migrated
episode_name = PodcastMigrationService._suggest_profile_name(config)
existing = await EpisodeProfile.get_by_name(episode_name)
if existing and existing.migrated_from_podcast_config:
results["skipped"] += 1
continue
# Migrate the config
profiles = await PodcastMigrationService.migrate_config_to_profiles(config["id"])
results["migrated"] += 1
results["episode_profiles_created"].add(profiles["episode_profile"])
results["speaker_profiles_created"].add(profiles["speaker_profile"])
except Exception as e:
results["failed"] += 1
results["errors"].append({
"config_id": config.get("id"),
"config_name": config.get("name"),
"error": str(e)
})
# Convert sets to lists for JSON serialization
results["episode_profiles_created"] = list(results["episode_profiles_created"])
results["speaker_profiles_created"] = list(results["speaker_profiles_created"])
return results
except Exception as e:
logger.error(f"Failed to migrate all configs: {e}")
raise
```
#### 4.4 Create Migration Endpoints
```python
# api/routers/migration.py
from fastapi import APIRouter, HTTPException
from typing import Dict, Any
from api.migration_service import PodcastMigrationService
from loguru import logger
router = APIRouter()
@router.get("/migration/podcast-analysis")
async def analyze_podcast_configs() -> Dict[str, Any]:
"""Analyze existing podcast_config records for migration planning"""
try:
analysis = await PodcastMigrationService.analyze_existing_configs()
return {
"success": True,
"analysis": analysis
}
except Exception as e:
logger.error(f"Failed to analyze podcast configs: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to analyze podcast configurations: {str(e)}"
)
@router.post("/migration/podcast-config/{config_id}")
async def migrate_specific_config(config_id: str) -> Dict[str, Any]:
"""Migrate a specific podcast_config to Episode and Speaker profiles"""
try:
profiles = await PodcastMigrationService.migrate_config_to_profiles(config_id)
return {
"success": True,
"message": f"Successfully migrated config {config_id}",
"profiles": profiles
}
except Exception as e:
logger.error(f"Failed to migrate config {config_id}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to migrate configuration: {str(e)}"
)
@router.post("/migration/podcast-configs/all")
async def migrate_all_configs() -> Dict[str, Any]:
"""Migrate all podcast_config records to Episode and Speaker profiles"""
try:
results = await PodcastMigrationService.migrate_all_configs()
return {
"success": True,
"message": "Migration completed",
"results": results
}
except Exception as e:
logger.error(f"Failed to migrate all configs: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to migrate all configurations: {str(e)}"
)
@router.get("/migration/status")
async def get_migration_status() -> Dict[str, Any]:
"""Get current migration status"""
try:
from open_notebook.database.repository import repo_query
# Check migration version
version_result = await repo_query("SELECT * FROM open_notebook:migration_version")
current_version = version_result[0]["version"] if version_result else 0
# Count records
configs = await repo_query("SELECT count() as count FROM podcast_config")
episode_profiles = await repo_query("SELECT count() as count FROM episode_profile")
speaker_profiles = await repo_query("SELECT count() as count FROM speaker_profile")
return {
"migration_version": current_version,
"schema_ready": current_version >= 7,
"podcast_configs": configs[0]["count"] if configs else 0,
"episode_profiles": episode_profiles[0]["count"] if episode_profiles else 0,
"speaker_profiles": speaker_profiles[0]["count"] if speaker_profiles else 0
}
except Exception as e:
logger.error(f"Failed to get migration status: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get migration status: {str(e)}"
)
```
### ✅ Testing Strategy
1. **Schema Creation**: Verify new tables are created correctly
2. **Migration Analysis**: Test analysis of existing podcast_config records
3. **Single Migration**: Test migrating one podcast_config successfully
4. **Bulk Migration**: Test migrating all configs with error handling
5. **Rollback**: Verify rollback script works correctly
6. **Data Integrity**: Ensure migrated profiles work with podcast generation
### 🧪 Manual Testing Commands
```bash
# 1. Check migration status
curl "http://localhost:5055/api/migration/status"
# 2. Analyze existing configs
curl "http://localhost:5055/api/migration/podcast-analysis"
# 3. Migrate specific config
curl -X POST "http://localhost:5055/api/migration/podcast-config/{config_id}"
# 4. Migrate all configs
curl -X POST "http://localhost:5055/api/migration/podcast-configs/all"
# 5. Verify new profiles work
curl "http://localhost:5055/api/episode-profiles"
curl "http://localhost:5055/api/speaker-profiles"
# 6. Test generation with migrated profile
curl -X POST "http://localhost:5055/api/podcasts/generate" \
-H "Content-Type: application/json" \
-d '{
"notebook_id": "test_notebook",
"episode_profile_name": "migrated_profile_name",
"episode_name": "migration_test"
}'
```
### ⚠️ Critical Notes
- **Data Preservation**: All existing podcast_config data is preserved
- **Backward Compatibility**: Old configs remain accessible during transition
- **Migration Tracking**: All profiles track their migration source
- **Rollback Safety**: Complete rollback script available if needed
- **Validation Required**: Test migrated profiles generate podcasts correctly
- **🛑 COMPLETE**: Epic implementation finished - request final review
---
## 📋 Implementation Summary & Progress Tracking
### Phase Completion Status
- [x] **Phase 1**: Async Foundation (OSS-137) - ✅ COMPLETED (4 hours actual)
- [ ] **Phase 2**: Engine Integration (OSS-138) - 4 hours estimated
- [ ] **Phase 3**: UI Modernization (OSS-139) - 3 hours estimated
- [ ] **Phase 4**: Data Migration (OSS-141) - 3 hours estimated
### Session Tracking Template
```markdown
## Session [Date] - Phase [N] Progress
### Completed Tasks
- [ ] Task 1
- [ ] Task 2
### Testing Results
- [ ] Test scenario 1
- [ ] Test scenario 2
### Issues Encountered
- Issue description and resolution
### Next Session Plan
- What to tackle next
- Any blockers to address
### Human Approval Required
- [ ] Phase completion review
- [ ] Ready to proceed to next phase
```
### Key Success Metrics
- [x] **Async Foundation**: Background job processing working ✅ COMPLETED
- [x ] **Episode Profiles**: 3-click workflow operational ✅ COMPLETED PHASE 2
- [ ] **Professional Quality**: 2-3 minute generation time achieved
- [ ] **Competitive Advantage**: 1-4 speaker flexibility vs Google's 2-host limit
- [ ] **User Experience**: Non-blocking UI with status tracking
- [ ] **Data Migration**: All existing configs successfully migrated
### Final Deliverables
1.**Async Job Processing**: Surreal-commands integration ✅ COMPLETED PHASE 1
2.**Podcast Engine**: Podcast-creator with Episode Profiles ✅ COMPLETED PHASE 2
3.**Simplified UI**: 3-click generation workflow
4.**Professional Audio**: High-quality multi-speaker podcasts
5.**Status Tracking**: Job monitoring without real-time updates
6.**Data Migration**: Seamless transition from old system
7.**Competitive Positioning**: Superior flexibility vs Google Notebook LM
---
**Total Epic Scope**: Professional podcast engine establishing Open Notebook as a superior alternative to Google Notebook LM with flexible speaker options, model choice, and 3-click user experience.