# 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; 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; 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; 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; 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; DEFINE FIELD IF NOT EXISTS transcript ON TABLE episode TYPE option; DEFINE FIELD IF NOT EXISTS outline ON TABLE episode TYPE option; DEFINE FIELD IF NOT EXISTS command ON TABLE episode TYPE record; -- 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.