PodCraft
+One prompt
{isLogin ? 'Login' : 'Sign Up'}
+ ++ {isLogin ? "Don't have an account? " : "Already have an account? "} + + {isLogin ? 'Sign Up' : 'Login'} + +
+diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..515dabd55ec39c26ffabfcaacf5c1d215e5f1e6c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000000000000000000000000000000000000..02b5107fa8a5ee07cbfac8dbab81cf176b023a51 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,22 @@ +name: Docker Image CI/CD + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Build the Docker image + run: | + docker build . --file Dockerfile --tag podcraft-app:$(date +%s) + + # Add deployment steps here if needed for your specific platform \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bceeb3054c73007ae575349fde60271461048f42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +backend/app/__pycache__ +backend/.env +frontend/podcraft/node_modules +backend/.venv/ +backend/venv/ +.DS_Store +frontend/podcraft/.DS_Store +backend/.DS_Store +frontend/.DS_Store +backend/temp_audio/* +!backend/temp_audio/Default/ +!backend/temp_audio/Default/final_podcast.mp3 +backend/temp/* +backend/app/agents/__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..49588d86fa9e4654865083eb0a770b085c12b940 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.9-slim + +WORKDIR /app + +# Install system dependencies including ffmpeg +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first to leverage Docker cache +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend code +COPY backend/app/ /app/app/ + +# Copy frontend build to static directory +COPY frontend/podcraft/build/ /app/static/ + +# Install additional packages needed for serving frontend +RUN pip install --no-cache-dir python-multipart + +# Create directory for temporary files +RUN mkdir -p /app/temp_audio + +# Set environment variables +ENV PYTHONPATH=/app +ENV MONGODB_URL="mongodb+srv://NageshBM:Nash166^@podcraft.ozqmc.mongodb.net/?retryWrites=true&w=majority&appName=Podcraft" + +# Expose the port the app runs on +EXPOSE 8000 + +# Command to run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.spaces b/Dockerfile.spaces new file mode 100644 index 0000000000000000000000000000000000000000..414fb0aa21256ede8c34f7c5af03d8d7b496b4c3 --- /dev/null +++ b/Dockerfile.spaces @@ -0,0 +1,39 @@ +FROM python:3.9-slim + +WORKDIR /app + +# Install system dependencies including ffmpeg +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first to leverage Docker cache +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend code +COPY backend/app /app/app/ + +# Copy frontend build to static directory if available +COPY frontend/podcraft/build/ /app/static/ + +# Install additional packages needed for serving frontend +RUN pip install --no-cache-dir python-multipart + +# Create directory for temporary files +RUN mkdir -p /app/temp_audio + +# Set environment variables +ENV PYTHONPATH=/app + +# Using Secrets from HuggingFace Spaces +# MongoDB_URL is hardcoded to the Atlas URL in this case +ENV MONGODB_URL="mongodb+srv://NageshBM:Nash166^@podcraft.ozqmc.mongodb.net/?retryWrites=true&w=majority&appName=Podcraft" + +# Expose the port the app runs on +EXPOSE 7860 + +# HuggingFace Spaces expects the app to run on port 7860 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..9dab75414d7def5b949b520dc585e004da5285e8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,199 @@ +from fastapi import FastAPI, Request, HTTPException, Depends, status, File, UploadFile, Form +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi.responses import JSONResponse, FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional, Dict, List, Union, Any +from datetime import datetime, timedelta +import jwt +from jwt.exceptions import PyJWTError +from passlib.context import CryptContext +import os +import shutil +import logging +import json +from motor.motor_asyncio import AsyncIOMotorClient +from decouple import config +import uuid +from bson.objectid import ObjectId +import asyncio +import time +import sys +from pathlib import Path + +# Import the original app modules +from app.models import * +from app.agents.podcast_manager import PodcastManager +from app.agents.researcher import Researcher +from app.agents.debate_agent import DebateAgent + +# Setup logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize FastAPI app +app = FastAPI(title="PodCraft API") + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins in production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Get MongoDB connection string from environment or config +MONGODB_URL = os.getenv("MONGODB_URL", config("MONGODB_URL", default="mongodb://localhost:27017")) + +# MongoDB client +client = AsyncIOMotorClient(MONGODB_URL) +db = client.podcraft +users = db.users +podcasts = db.podcasts +agents = db.agents +workflows = db.workflows + +# Initialize podcast manager +podcast_manager = PodcastManager() + +# Initialize researcher +researcher = Researcher() + +# Initialize debate agent +debate_agent = DebateAgent() + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# JWT settings +SECRET_KEY = os.getenv("SECRET_KEY", config("SECRET_KEY", default="your-secret-key")) +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 1 week + +# OAuth2 scheme +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + +# Keep the original authentication and API routes +# Include all the functions and routes from the original main.py + +def create_access_token(data: dict): + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +async def get_current_user(token: str = Depends(oauth2_scheme)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except PyJWTError: + raise credentials_exception + + user = await users.find_one({"username": username}) + if user is None: + raise credentials_exception + + # Convert ObjectId to string for JSON serialization + user["_id"] = str(user["_id"]) + + return user + +# Include all the API routes from the original main.py here +# ... + +# Mount static files for frontend +static_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "static")) +app.mount("/static", StaticFiles(directory=static_path), name="static") + +# Add route to serve the frontend +@app.get("/", response_class=HTMLResponse) +async def serve_frontend(): + html_file = os.path.join(static_path, "index.html") + if os.path.exists(html_file): + with open(html_file, "r") as f: + return f.read() + else: + return HTMLResponse(content="
Frontend not found.
") + +# Add route to serve audio files +@app.get("/audio/{path:path}") +async def serve_audio(path: str): + audio_file = os.path.join("/app/temp_audio", path) + if os.path.exists(audio_file): + return FileResponse(audio_file) + else: + raise HTTPException(status_code=404, detail="Audio file not found") + +# Route for health check +@app.get("/health") +async def health(): + return {"status": "healthy"} + +# Include all the original API routes here from the original main.py + +@app.post("/signup") +async def signup(user: UserCreate): + # Check if username exists + existing_user = await users.find_one({"username": user.username}) + if existing_user: + raise HTTPException(status_code=400, detail="Username already registered") + + # Hash the password + hashed_password = get_password_hash(user.password) + + # Create new user + user_obj = {"username": user.username, "password": hashed_password} + new_user = await users.insert_one(user_obj) + + # Create access token + access_token = create_access_token(data={"sub": user.username}) + + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/token", response_model=Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + user = await users.find_one({"username": form_data.username}) + if not user or not verify_password(form_data.password, user["password"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = create_access_token(data={"sub": form_data.username}) + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/login", response_model=Token) +async def login(request: Request, user: UserLogin): + db_user = await users.find_one({"username": user.username}) + if not db_user or not verify_password(user.password, db_user["password"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password" + ) + + access_token = create_access_token(data={"sub": user.username}) + return {"access_token": access_token, "token_type": "bearer"} + +# Add all the other API routes from the original main.py +# ... + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/app/agents/debaters.py b/backend/app/agents/debaters.py new file mode 100644 index 0000000000000000000000000000000000000000..f0ea13fde57f920361688dcce5674f2b44bdf4f8 --- /dev/null +++ b/backend/app/agents/debaters.py @@ -0,0 +1,206 @@ +from langchain_openai import ChatOpenAI +from langchain_core.prompts import ChatPromptTemplate +from decouple import config +from typing import Dict, List, AsyncGenerator +import json +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +OPENAI_API_KEY = config('OPENAI_API_KEY') + +# Debug logging +print(f"\nDebaters - Loaded OpenAI API Key: {OPENAI_API_KEY[:7]}...") +print(f"Key starts with 'sk-proj-': {OPENAI_API_KEY.startswith('sk-proj-')}") +print(f"Key starts with 'sk-': {OPENAI_API_KEY.startswith('sk-')}\n") + +believer_turn_prompt = ChatPromptTemplate.from_messages([ + ("system", """You are an optimistic and enthusiastic podcast host who sees the positive potential in new developments. + Your responses should be engaging, conversational, and STRICTLY LIMITED TO 100 WORDS. + Focus on the opportunities, benefits, and positive implications of the topic. + Maintain a non-chalant, happy, podcast-style tone while being informative. + Your name is {name}, use 'I' when referring to yourself."""), + ("user", "Based on this research and the skeptic's last response (if any), provide your perspective for turn {turn_number}:\n\nResearch: {research}\nSkeptic's last response: {skeptic_response}") +]) + +skeptic_turn_prompt = ChatPromptTemplate.from_messages([ + ("system", """You are a thoughtful and critical podcast host who carefully examines potential drawbacks and challenges. + Your responses should be engaging, conversational, and STRICTLY LIMITED TO 100 WORDS. + Focus on potential risks, limitations, and areas needing careful consideration. + Maintain a enthusiastic and angry, podcast-style tone while being informative. + Your name is {name}, use 'I' when referring to yourself."""), + ("user", "Based on this research and the believer's last response (if any), provide your perspective for turn {turn_number}:\n\nResearch: {research}\nBeliever's last response: {believer_response}") +]) + +# Initialize the LLMs with streaming +believer_llm = ChatOpenAI( + model="gpt-4o-mini", + temperature=0.7, + api_key=OPENAI_API_KEY, + streaming=True +) + +skeptic_llm = ChatOpenAI( + model="gpt-4o-mini", + temperature=0.7, + api_key=OPENAI_API_KEY, + streaming=True +) + +def chunk_text(text: str, max_length: int = 3800) -> List[str]: + """Split text into chunks of maximum length while preserving sentence boundaries.""" + # Split into sentences and trim whitespace + sentences = [s.strip() for s in text.split('.')] + sentences = [s + '.' for s in sentences if s] + + chunks = [] + current_chunk = [] + current_length = 0 + + for sentence in sentences: + sentence_length = len(sentence) + if current_length + sentence_length > max_length: + if current_chunk: # If we have accumulated sentences, join them and add to chunks + chunks.append(' '.join(current_chunk)) + current_chunk = [sentence] + current_length = sentence_length + else: # If a single sentence is too long, split it + if sentence_length > max_length: + words = sentence.split() + temp_chunk = [] + temp_length = 0 + for word in words: + if temp_length + len(word) + 1 > max_length: + chunks.append(' '.join(temp_chunk)) + temp_chunk = [word] + temp_length = len(word) + else: + temp_chunk.append(word) + temp_length += len(word) + 1 + if temp_chunk: + chunks.append(' '.join(temp_chunk)) + else: + chunks.append(sentence) + else: + current_chunk.append(sentence) + current_length += sentence_length + + if current_chunk: + chunks.append(' '.join(current_chunk)) + + return chunks + +async def generate_debate_stream(research: str, believer_name: str, skeptic_name: str) -> AsyncGenerator[str, None]: + """ + Generate a streaming podcast-style debate between believer and skeptic agents with alternating turns. + """ + try: + turns = 3 # Number of turns for each speaker + skeptic_last_response = "" + believer_last_response = "" + + # Start with skeptic for first turn + for turn in range(1, turns + 1): + logger.info(f"Starting skeptic ({skeptic_name}) turn {turn}") + skeptic_response = "" + # Stream skeptic's perspective + async for chunk in skeptic_llm.astream( + skeptic_turn_prompt.format( + research=research, + name=skeptic_name, + turn_number=turn, + believer_response=believer_last_response + ) + ): + skeptic_response += chunk.content + yield json.dumps({ + "type": "skeptic", + "name": skeptic_name, + "content": chunk.content, + "turn": turn + }) + "\n" + skeptic_last_response = skeptic_response + logger.info(f"Skeptic turn {turn}: {skeptic_response}") + + logger.info(f"Starting believer ({believer_name}) turn {turn}") + believer_response = "" + # Stream believer's perspective + async for chunk in believer_llm.astream( + believer_turn_prompt.format( + research=research, + name=believer_name, + turn_number=turn, + skeptic_response=skeptic_last_response + ) + ): + believer_response += chunk.content + yield json.dumps({ + "type": "believer", + "name": believer_name, + "content": chunk.content, + "turn": turn + }) + "\n" + believer_last_response = believer_response + logger.info(f"Believer turn {turn}: {believer_response}") + + except Exception as e: + logger.error(f"Error in debate generation: {str(e)}") + yield json.dumps({"type": "error", "content": str(e)}) + "\n" + +async def generate_debate(research: str, believer_name: str, skeptic_name: str) -> List[Dict]: + """ + Generate a complete podcast-style debate between believer and skeptic agents. + Kept for compatibility with existing code. + """ + try: + logger.info(f"Starting believer ({believer_name}) response generation") + # Get believer's perspective + believer_response = await believer_llm.ainvoke( + believer_prompt.format(research=research, name=believer_name) + ) + logger.info(f"Believer response: {believer_response.content}") + + logger.info(f"Starting skeptic ({skeptic_name}) response generation") + # Get skeptic's perspective + skeptic_response = await skeptic_llm.ainvoke( + skeptic_prompt.format(research=research, name=skeptic_name) + ) + logger.info(f"Skeptic response: {skeptic_response.content}") + + # Create conversation blocks with chunked text + blocks = [] + + # Add believer chunks + believer_chunks = chunk_text(believer_response.content) + for i, chunk in enumerate(believer_chunks): + blocks.append({ + "name": f"{believer_name}'s Perspective (Part {i+1})", + "input": chunk, + "silence_before": 1, + "voice_id": "OA001", # Will be updated based on selected voice + "emotion": "neutral", + "model": "tts-1", + "speed": 1, + "duration": 0 + }) + + # Add skeptic chunks + skeptic_chunks = chunk_text(skeptic_response.content) + for i, chunk in enumerate(skeptic_chunks): + blocks.append({ + "name": f"{skeptic_name}'s Perspective (Part {i+1})", + "input": chunk, + "silence_before": 1, + "voice_id": "OA002", # Will be updated based on selected voice + "emotion": "neutral", + "model": "tts-1", + "speed": 1, + "duration": 0 + }) + + return blocks + except Exception as e: + logger.error(f"Error in debate generation: {str(e)}") + return [] \ No newline at end of file diff --git a/backend/app/agents/podcast_manager.py b/backend/app/agents/podcast_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..b84df1ddcc464898ce11e1cf0c9cd64be1770f2e --- /dev/null +++ b/backend/app/agents/podcast_manager.py @@ -0,0 +1,393 @@ +import requests +import json +import os +import shutil +import subprocess +from datetime import datetime +from decouple import config +from motor.motor_asyncio import AsyncIOMotorClient +from typing import Dict, List +import logging +from fastapi import HTTPException, status + +logger = logging.getLogger(__name__) + +class Settings: + MONGODB_URL = config('MONGODB_URL') + SECRET_KEY = config('SECRET_KEY') + OPENAI_API_KEY = config('OPENAI_API_KEY') + # Other settings... + +settings = Settings() + +client = AsyncIOMotorClient(settings.MONGODB_URL) +db = client.podcraft +podcasts = db.podcasts + +class PodcastManager: + def __init__(self): + self.tts_url = "https://api.openai.com/v1/audio/speech" + self.headers = { + "Authorization": f"Bearer {settings.OPENAI_API_KEY}", + "Content-Type": "application/json" + } + # Create absolute path for temp directory + self.temp_dir = os.path.abspath("temp_audio") + os.makedirs(self.temp_dir, exist_ok=True) + + # Define allowed voices + self.allowed_voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer", "ash", "sage", "coral"] + + def generate_speech(self, text: str, voice_id: str, filename: str) -> bool: + """Generate speech using OpenAI's TTS API.""" + try: + # Debug logging for voice selection + print(f"\n=== TTS Generation Details ===") + print(f"File: {filename}") + print(f"Voice ID (original): {voice_id}") + print(f"Voice ID (lowercase): {voice_id.lower()}") + print(f"Allowed voices: {self.allowed_voices}") + + # Validate and normalize voice_id + voice = voice_id.lower().strip() + if voice not in self.allowed_voices: + print(f"Warning: Invalid voice ID: {voice_id}. Using default voice 'alloy'") + voice = "alloy" + + print(f"Final voice selection: {voice}") + + # Ensure the output directory exists + output_dir = os.path.dirname(filename) + os.makedirs(output_dir, exist_ok=True) + + payload = { + "model": "tts-1", + "input": text, + "voice": voice + } + + print(f"TTS API payload: {json.dumps(payload, indent=2)}") + print(f"Request headers: {json.dumps({k: '***' if k == 'Authorization' else v for k, v in self.headers.items()}, indent=2)}") + + response = requests.post(self.tts_url, json=payload, headers=self.headers) + if response.status_code != 200: + print(f"API error response: {response.status_code} - {response.text}") + return False + + # Write the audio content to the file + with open(filename, "wb") as f: + f.write(response.content) + + print(f"Successfully generated speech file: {filename}") + print(f"File size: {os.path.getsize(filename)} bytes") + + # Verify the file exists and has content + if not os.path.exists(filename) or os.path.getsize(filename) == 0: + print(f"Error: Generated file is empty or does not exist: {filename}") + return False + + return True + except Exception as e: + print(f"Error generating speech: {str(e)}") + logger.exception(f"Error generating speech: {str(e)}") + return False + + def merge_audio_files(self, audio_files: List[str], output_file: str) -> bool: + """Merge multiple audio files into one using ffmpeg.""" + try: + # Ensure output directory exists + output_dir = os.path.dirname(os.path.abspath(output_file)) + os.makedirs(output_dir, exist_ok=True) + + if not audio_files: + print("No audio files to merge") + return False + + # Verify all input files exist + for audio_file in audio_files: + if not os.path.exists(audio_file): + print(f"Audio file does not exist: {audio_file}") + return False + + # Ensure all paths are absolute + output_file = os.path.abspath(output_file) + output_dir = os.path.dirname(output_file) + os.makedirs(output_dir, exist_ok=True) + + # Create temporary files in the same directory + list_file = os.path.join(output_dir, "files.txt") + silence_file = os.path.join(output_dir, "silence.mp3") + + print(f"Output directory: {output_dir}") + print(f"List file: {list_file}") + print(f"Silence file: {silence_file}") + + # Generate shorter silence file (0.3 seconds instead of 1 second) + silence_result = subprocess.run([ + 'ffmpeg', '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', + '-t', '0.3', '-q:a', '9', '-acodec', 'libmp3lame', silence_file + ], capture_output=True, text=True) + + if silence_result.returncode != 0: + print(f"Error generating silence file: {silence_result.stderr}") + return False + + if not os.path.exists(silence_file): + print("Failed to create silence file") + return False + + # IMPORTANT: The order here determines the final audio order + print("\nGenerating files list in exact provided order:") + try: + with open(list_file, "w", encoding='utf-8') as f: + for i, audio_file in enumerate(audio_files): + abs_audio_path = os.path.abspath(audio_file) + print(f"{i+1}. Adding audio file: {os.path.basename(abs_audio_path)}") + # Use forward slashes for ffmpeg compatibility + abs_audio_path = abs_audio_path.replace('\\', '/') + silence_path = silence_file.replace('\\', '/') + f.write(f"file '{abs_audio_path}'\n") + # Add a shorter silence after each audio segment (except the last one) + if i < len(audio_files) - 1: + f.write(f"file '{silence_path}'\n") + except Exception as e: + print(f"Error writing list file: {str(e)}") + return False + + if not os.path.exists(list_file): + print("Failed to create list file") + return False + + # Print the contents of the list file for debugging + print("\nContents of files.txt:") + with open(list_file, 'r', encoding='utf-8') as f: + print(f.read()) + + # Merge all files using the concat demuxer with optimized settings + try: + # Use concat demuxer with additional parameters for better playback + result = subprocess.run( + ['ffmpeg', '-f', 'concat', '-safe', '0', '-i', list_file, + '-c:a', 'libmp3lame', '-q:a', '4', '-ar', '44100', + output_file], + capture_output=True, + text=True, + check=True + ) + except subprocess.CalledProcessError as e: + logger.error(f"FFmpeg command failed: {e.stderr}") + return False + + # Verify the output file was created + if not os.path.exists(output_file): + print("Failed to create output file") + return False + + print(f"Successfully created merged audio file: {output_file}") + return True + except Exception as e: + print(f"Error merging audio files: {str(e)}") + return False + + async def create_podcast( + self, + topic: str, + research: str, + conversation_blocks: List[Dict], + believer_voice_id: str, + skeptic_voice_id: str, + user_id: str = None + ) -> Dict: + """Create a podcast by converting text to speech and storing the results.""" + podcast_temp_dir = None + try: + # Debug logging for voice IDs + print(f"\nPodcast Creation - Voice Configuration:") + print(f"Believer Voice ID: {believer_voice_id}") + print(f"Skeptic Voice ID: {skeptic_voice_id}") + + # Create a unique directory with absolute path + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + podcast_temp_dir = os.path.abspath(os.path.join(self.temp_dir, timestamp)) + os.makedirs(podcast_temp_dir, exist_ok=True) + + print(f"Created temp directory: {podcast_temp_dir}") + print(f"Processing conversation blocks: {json.dumps(conversation_blocks, indent=2)}") + + audio_files = [] + + # Process the blocks differently based on format: + # 1. New turn-based format with "type" and "turn" fields + # 2. Blocks with "input" field but no turn-based structure (old format) + # 3. Blocks with both "input" field and turn-based structure (mixed format) + + # First check: New format blocks with type and turn + if any("type" in block and "turn" in block and "content" in block for block in conversation_blocks): + print("\nProcessing new format blocks with type, turn, and content fields") + + # Process conversation blocks in the EXACT order they were provided + # This ensures proper alternation between speakers as specified by the caller + + for idx, block in enumerate(conversation_blocks): + if "type" in block and "content" in block and "turn" in block: + turn = block.get("turn", 0) + agent_type = block.get("type", "") + content = block.get("content", "") + + if not content.strip(): # Skip empty content + continue + + # Use the correct voice based on agent type + voice_id = believer_voice_id if agent_type == "believer" else skeptic_voice_id + file_prefix = "believer" if agent_type == "believer" else "skeptic" + + # Create a unique filename with turn number + audio_file = os.path.join(podcast_temp_dir, f"{file_prefix}_turn_{turn}_{idx}.mp3") + + print(f"\nProcessing {agent_type} turn {turn} (index {idx}) with voice {voice_id}") + print(f"Content preview: {content[:100]}...") + + if self.generate_speech(content, voice_id, audio_file): + # Add to our audio files list IN THE ORIGINAL ORDER + audio_files.append(audio_file) + print(f"Generated {agent_type} audio for turn {turn}, added to position {len(audio_files)}") + else: + raise Exception(f"Failed to generate audio for {agent_type} turn {turn}") + + # Second check: Blocks with input field and possibly turn information + elif any("input" in block for block in conversation_blocks): + print("\nProcessing blocks with input field") + + # Check if these blocks also have type and turn information + has_turn_info = any("turn" in block and "type" in block for block in conversation_blocks) + + if has_turn_info: + print("Blocks have both input field and turn-based structure - using mixed format") + # Sort by turn if available, ensuring proper sequence + sorted_blocks = sorted(conversation_blocks, key=lambda b: b.get("turn", float('inf'))) + + for idx, block in enumerate(sorted_blocks): + if "input" in block and block["input"].strip(): + # Determine voice based on type field or name + if "type" in block: + is_believer = block["type"] == "believer" + else: + is_believer = "Believer" in block.get("name", "") or block.get("name", "").lower().startswith("alloy") + + voice_id = believer_voice_id if is_believer else skeptic_voice_id + speaker_type = "believer" if is_believer else "skeptic" + turn = block.get("turn", idx + 1) + + print(f"\nProcessing {speaker_type} block with turn {turn} using voice {voice_id}") + audio_file = os.path.join(podcast_temp_dir, f"{speaker_type}_turn_{turn}_{idx}.mp3") + + if self.generate_speech(block["input"], voice_id, audio_file): + audio_files.append(audio_file) + print(f"Generated audio for {speaker_type} turn {turn}") + else: + raise Exception(f"Failed to generate audio for {speaker_type} turn {turn}") + else: + # Old format - process blocks sequentially as they appear + print("Processing old format blocks sequentially") + for i, block in enumerate(conversation_blocks): + if "input" in block and block["input"].strip(): + # Check for either "Believer" in name or if the name starts with "alloy" + is_believer = "Believer" in block.get("name", "") or block.get("name", "").lower().startswith("alloy") + voice_id = believer_voice_id if is_believer else skeptic_voice_id + speaker_type = "believer" if is_believer else "skeptic" + + print(f"\nProcessing {speaker_type} block {i+1} with voice {voice_id}") + print(f"Block name: {block.get('name', '')}") # Debug logging + + audio_file = os.path.join(podcast_temp_dir, f"part_{i+1}.mp3") + if self.generate_speech(block["input"], voice_id, audio_file): + audio_files.append(audio_file) + print(f"Generated audio for part {i+1}") + else: + raise Exception(f"Failed to generate audio for part {i+1}") + else: + raise Exception("Invalid conversation blocks format - no recognizable structure found") + + if not audio_files: + raise Exception("No audio files were generated from the conversation blocks") + + print(f"\nGenerated {len(audio_files)} audio files in total") + + # Print the final order of audio files for verification + print("\nFinal audio file order before merging:") + for i, file in enumerate(audio_files): + print(f"{i+1}. {os.path.basename(file)}") + + # Merge all audio files + final_audio = os.path.join(podcast_temp_dir, "final_podcast.mp3") + print(f"Merging to final audio: {final_audio}") + + if not self.merge_audio_files(audio_files, final_audio): + raise Exception("Failed to merge audio files") + + # Calculate audio duration using ffprobe + duration = 0 + try: + cmd = [ + 'ffprobe', + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + final_audio + ] + duration_result = subprocess.run(cmd, capture_output=True, text=True) + if duration_result.returncode == 0: + duration = float(duration_result.stdout.strip()) + print(f"Audio duration: {duration} seconds") + else: + print(f"Failed to get audio duration: {duration_result.stderr}") + except Exception as e: + print(f"Error calculating duration: {str(e)}") + # Don't fail the entire process for duration calculation + + podcast_doc = { + "topic": topic, + "research": research, + "conversation_blocks": conversation_blocks, + "audio_path": final_audio, + "created_at": datetime.utcnow(), + "believer_voice_id": believer_voice_id, + "skeptic_voice_id": skeptic_voice_id, + "user_id": user_id, + "duration": duration # Add duration to MongoDB document + } + + result = await podcasts.insert_one(podcast_doc) + + # Clean up individual audio files but keep the final one + for audio_file in audio_files: + if os.path.exists(audio_file): + os.remove(audio_file) + + return { + "podcast_id": str(result.inserted_id), + "audio_path": final_audio, + "topic": topic, + "duration": duration # Return duration in the result + } + + except Exception as e: + # Clean up the temp directory in case of error + if os.path.exists(podcast_temp_dir): + shutil.rmtree(podcast_temp_dir) + logger.exception(f"Error in podcast creation: {str(e)}") + return { + "error": str(e) + } + + async def get_podcast(self, podcast_id: str) -> Dict: + """Retrieve a podcast by ID.""" + try: + from bson.objectid import ObjectId + podcast = await podcasts.find_one({"_id": ObjectId(podcast_id)}) + if podcast: + podcast["_id"] = str(podcast["_id"]) + return podcast + return {"error": "Podcast not found"} + except Exception as e: + return {"error": str(e)} \ No newline at end of file diff --git a/backend/app/agents/researcher.py b/backend/app/agents/researcher.py new file mode 100644 index 0000000000000000000000000000000000000000..b8d39cf9f4f31de8211f64fa109c1ff06cb9f748 --- /dev/null +++ b/backend/app/agents/researcher.py @@ -0,0 +1,153 @@ +from langchain_core.prompts import ChatPromptTemplate +from langchain_openai import ChatOpenAI +from langchain_community.tools.tavily_search import TavilySearchResults +from langchain.agents import AgentExecutor, create_openai_functions_agent +from decouple import config +from typing import AsyncGenerator, List +import os +import json + +# Get API keys from environment +TAVILY_API_KEY = config('TAVILY_API_KEY') +OPENAI_API_KEY = config('OPENAI_API_KEY') + +# Debug logging +print(f"\nLoaded OpenAI API Key: {OPENAI_API_KEY[:7]}...") +print(f"Key starts with 'sk-proj-': {OPENAI_API_KEY.startswith('sk-proj-')}") +print(f"Key starts with 'sk-': {OPENAI_API_KEY.startswith('sk-')}\n") + +# Set Tavily API key in environment +os.environ["TAVILY_API_KEY"] = TAVILY_API_KEY + +# Initialize the search tool +search_tool = TavilySearchResults(tavily_api_key=TAVILY_API_KEY) + +# List of available tools for the prompt +tools_description = """ +Available tools: +- TavilySearchResults: A search tool that provides comprehensive web search results. Use this to gather information about topics. +""" + +# Create the prompt template +researcher_prompt = ChatPromptTemplate.from_messages([ + ("system", """You are an expert researcher tasked with gathering comprehensive information on given topics. + Your goal is to provide detailed, factual information limited to 500 words. + Focus on key points, recent developments, and verified facts. + Structure your response clearly with main points and supporting details. + Keep your response concise and focused. + + {tools} + + Remember to provide accurate and up-to-date information."""), + ("user", "{input}"), + ("assistant", "{agent_scratchpad}") +]) + +# Initialize the LLM with streaming +researcher_llm = ChatOpenAI( + model="gpt-4o-mini", + temperature=0.3, + api_key=OPENAI_API_KEY, + streaming=True +) + +# Create the agent +researcher_agent = create_openai_functions_agent( + llm=researcher_llm, + prompt=researcher_prompt, + tools=[search_tool] +) + +# Create the agent executor +researcher_executor = AgentExecutor( + agent=researcher_agent, + tools=[search_tool], + verbose=True, + handle_parsing_errors=True, + return_intermediate_steps=True +) + +def chunk_text(text: str, max_length: int = 3800) -> List[str]: + """Split text into chunks of maximum length while preserving sentence boundaries.""" + # Split into sentences and trim whitespace + sentences = [s.strip() for s in text.split('.')] + sentences = [s + '.' for s in sentences if s] + + chunks = [] + current_chunk = [] + current_length = 0 + + for sentence in sentences: + sentence_length = len(sentence) + if current_length + sentence_length > max_length: + if current_chunk: # If we have accumulated sentences, join them and add to chunks + chunks.append(' '.join(current_chunk)) + current_chunk = [sentence] + current_length = sentence_length + else: # If a single sentence is too long, split it + if sentence_length > max_length: + words = sentence.split() + temp_chunk = [] + temp_length = 0 + for word in words: + if temp_length + len(word) + 1 > max_length: + chunks.append(' '.join(temp_chunk)) + temp_chunk = [word] + temp_length = len(word) + else: + temp_chunk.append(word) + temp_length += len(word) + 1 + if temp_chunk: + chunks.append(' '.join(temp_chunk)) + else: + chunks.append(sentence) + else: + current_chunk.append(sentence) + current_length += sentence_length + + if current_chunk: + chunks.append(' '.join(current_chunk)) + + return chunks + +async def research_topic_stream(topic: str) -> AsyncGenerator[str, None]: + """ + Research a topic and stream the results as they are generated. + """ + try: + async for chunk in researcher_executor.astream( + { + "input": f"Research this topic thoroughly: {topic}", + "tools": tools_description + } + ): + if isinstance(chunk, dict): + # Stream intermediate steps for transparency + if "intermediate_steps" in chunk: + for step in chunk["intermediate_steps"]: + yield json.dumps({"type": "intermediate", "content": str(step)}) + "\n" + + # Stream the final output + if "output" in chunk: + yield json.dumps({"type": "final", "content": chunk["output"]}) + "\n" + else: + yield json.dumps({"type": "chunk", "content": str(chunk)}) + "\n" + except Exception as e: + yield json.dumps({"type": "error", "content": str(e)}) + "\n" + +async def research_topic(topic: str) -> str: + """ + Research a topic and return the complete result. + Kept for compatibility with existing code. + """ + try: + result = await researcher_executor.ainvoke( + { + "input": f"Research this topic thoroughly: {topic}", + "tools": tools_description + } + ) + return result["output"] + except Exception as e: + print(f"Error in research: {str(e)}") + return "Error occurred during research." \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000000000000000000000000000000000000..5745bb452b7d32c89e739061321ec115267fc57a --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,13 @@ +from motor.motor_asyncio import AsyncIOMotorClient +from decouple import config + +MONGODB_URL = config('MONGODB_URL') +client = AsyncIOMotorClient(MONGODB_URL) +db = client.podcraft + +# Collections +users = db.users +podcasts = db.podcasts +agents = db.agents # New collection for storing agent configurations +workflows = db.workflows # Collection for storing workflow configurations +workflows = db.workflows # Collection for storing workflow configurations \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fdff933ed17e18111432df646c0e8604bca2b4b0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,1020 @@ +from fastapi import FastAPI, HTTPException, Depends, status, Request +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from passlib.context import CryptContext +from datetime import datetime, timedelta +from jose import JWTError, jwt +from decouple import config +import logging +from .database import users, podcasts, agents, workflows +from .models import ( + UserCreate, UserLogin, Token, UserUpdate, UserResponse, + PodcastRequest, PodcastResponse, AgentCreate, AgentResponse, + TextPodcastRequest, TextPodcastResponse, + WorkflowCreate, WorkflowResponse, InsightsData, TranscriptEntry +) +from .agents.researcher import research_topic, research_topic_stream +from .agents.debaters import generate_debate, generate_debate_stream, chunk_text +from .agents.podcast_manager import PodcastManager +import json +import os +import shutil +from typing import List +import time +from bson import ObjectId + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Debug environment variables +openai_key = config('OPENAI_API_KEY') +logger.info(f"Loaded OpenAI API Key at startup: {openai_key[:7]}...") +logger.info(f"Key starts with 'sk-proj-': {openai_key.startswith('sk-proj-')}") +logger.info(f"Key starts with 'sk-': {openai_key.startswith('sk-')}") + +app = FastAPI() + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], # React app + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["*"] # Expose all headers +) + +# Create necessary directories if they don't exist +os.makedirs("temp", exist_ok=True) +os.makedirs("temp_audio", exist_ok=True) + +# Make sure the directory paths are absolute +TEMP_AUDIO_DIR = os.path.abspath("temp_audio") +print(f"Mounting temp_audio directory: {TEMP_AUDIO_DIR}") + +# Mount static directory for audio files +app.mount("/audio", StaticFiles(directory=TEMP_AUDIO_DIR), name="audio") + +# Security +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +SECRET_KEY = config("SECRET_KEY") +ACCESS_TOKEN_EXPIRE_MINUTES = int(config("ACCESS_TOKEN_EXPIRE_MINUTES")) + +# Helper functions +def create_access_token(data: dict): + expires = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + data.update({"exp": expires}) + token = jwt.encode(data, SECRET_KEY, algorithm="HS256") + return token + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +async def get_current_user(token: str = Depends(oauth2_scheme)): + logger.info("Authenticating user with token") + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials" + ) + try: + logger.info("Decoding JWT token") + payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + username: str = payload.get("sub") + if username is None: + logger.error("No username found in token") + raise credentials_exception + logger.info(f"Token decoded successfully for user: {username}") + except JWTError as e: + logger.error(f"JWT Error: {str(e)}") + raise credentials_exception + + user = await users.find_one({"username": username}) + if user is None: + logger.error(f"No user found for username: {username}") + raise credentials_exception + logger.info(f"User authenticated successfully: {username}") + return user + +# Initialize PodcastManager +podcast_manager = PodcastManager() + +# Routes +@app.post("/signup") +async def signup(user: UserCreate): + # Check if username exists + if await users.find_one({"username": user.username}): + raise HTTPException(status_code=400, detail="Username already registered") + + # Create new user + user_dict = user.dict() + user_dict["password"] = get_password_hash(user.password) + await users.insert_one(user_dict) + + # Create and return token after signup + access_token = create_access_token(data={"sub": user.username}) + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/token", response_model=Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + logger.info(f"Token request for user: {form_data.username}") + # Find user + db_user = await users.find_one({"username": form_data.username}) + if not db_user or not verify_password(form_data.password, db_user["password"]): + logger.error(f"Failed token request for user: {form_data.username}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Create access token + access_token = create_access_token(data={"sub": form_data.username}) + logger.info(f"Token generated successfully for user: {form_data.username}") + return {"access_token": access_token, "token_type": "bearer"} + +@app.post("/login", response_model=Token) +async def login(request: Request, user: UserLogin): + logger.info(f"Login attempt for user: {user.username}") + # Find user + db_user = await users.find_one({"username": user.username}) + if not db_user or not verify_password(user.password, db_user["password"]): + logger.error(f"Failed login attempt for user: {user.username}") + raise HTTPException( + status_code=401, + detail="Incorrect username or password" + ) + + # Create access token + access_token = create_access_token(data={"sub": user.username}) + logger.info(f"Login successful for user: {user.username}") + return {"access_token": access_token, "token_type": "bearer"} + +@app.get("/user/me", response_model=UserResponse) +async def get_user_profile(current_user: dict = Depends(get_current_user)): + return { + "username": current_user["username"] + } + +@app.put("/user/update-password") +async def update_password(user_update: UserUpdate, current_user: dict = Depends(get_current_user)): + hashed_password = get_password_hash(user_update.password) + await users.update_one( + {"username": current_user["username"]}, + {"$set": {"password": hashed_password}} + ) + return {"message": "Password updated successfully"} + +@app.get("/") +async def root(): + return {"message": "Welcome to PodCraft API"} + +# New podcast endpoints +@app.post("/generate-podcast", response_model=PodcastResponse) +async def generate_podcast(request: Request, podcast_req: PodcastRequest, current_user: dict = Depends(get_current_user)): + logger.info(f"Received podcast generation request for topic: {podcast_req.topic}") + logger.info(f"Request headers: {dict(request.headers)}") + + try: + # Step 1: Research the topic + logger.info("Starting research phase") + research_results = await research_topic(podcast_req.topic) + logger.info("Research phase completed") + + # Step 2: Generate debate between believer and skeptic + logger.info("Starting debate generation") + conversation_blocks = await generate_debate( + research=research_results, + believer_name=podcast_req.believer_voice_id, + skeptic_name=podcast_req.skeptic_voice_id + ) + + if not conversation_blocks: + logger.error("Failed to generate debate - no conversation blocks returned") + raise HTTPException(status_code=500, detail="Failed to generate debate") + + logger.info("Debate generation completed") + + # Step 3: Create podcast using TTS and store in MongoDB + logger.info("Starting podcast creation with TTS") + result = await podcast_manager.create_podcast( + topic=podcast_req.topic, + research=research_results, + conversation_blocks=conversation_blocks, + believer_voice_id=podcast_req.believer_voice_id, + skeptic_voice_id=podcast_req.skeptic_voice_id + ) + + if "error" in result: + logger.error(f"Error in podcast creation: {result['error']}") + raise HTTPException(status_code=500, detail=result["error"]) + + logger.info(f"Podcast generated successfully with ID: {result.get('podcast_id')}") + return result + except Exception as e: + logger.error(f"Error in podcast generation: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/podcast/{podcast_id}", response_model=PodcastResponse) +async def get_podcast(podcast_id: str, current_user: dict = Depends(get_current_user)): + try: + result = await podcast_manager.get_podcast(podcast_id) + if "error" in result: + raise HTTPException(status_code=404, detail=result["error"]) + return result + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/generate-podcast/stream") +async def generate_podcast_stream(request: PodcastRequest, current_user: dict = Depends(get_current_user)): + async def generate(): + try: + # Store complete responses for podcast creation + believer_turns = {} # Store responses by turn number + skeptic_turns = {} # Store responses by turn number + + # Stream research results + logger.info("Starting research phase (streaming)") + research_results = "" + async for chunk in research_topic_stream(request.topic): + yield chunk + if isinstance(chunk, str) and "final" in chunk: + data = json.loads(chunk) + if data["type"] == "final": + research_results = data["content"] + + # Stream debate and track turns properly + logger.info("Starting debate phase (streaming)") + async for chunk in generate_debate_stream( + research=research_results, + believer_name=request.believer_voice_id, + skeptic_name=request.skeptic_voice_id + ): + yield chunk + # Parse the chunk + data = json.loads(chunk) + + # Track responses by turn to maintain proper ordering + if data["type"] == "believer" and "turn" in data: + turn = data["turn"] + if turn not in believer_turns: + believer_turns[turn] = "" + believer_turns[turn] += data["content"] + elif data["type"] == "skeptic" and "turn" in data: + turn = data["turn"] + if turn not in skeptic_turns: + skeptic_turns[turn] = "" + skeptic_turns[turn] += data["content"] + + # Create strictly alternating conversation blocks for podcast + blocks = [] + + # Find the maximum turn number + max_turn = max( + max(skeptic_turns.keys()) if skeptic_turns else 0, + max(believer_turns.keys()) if believer_turns else 0 + ) + + logger.info(f"Creating podcast with {len(believer_turns)} believer turns and {len(skeptic_turns)} skeptic turns") + logger.info(f"Max turn number: {max_turn}") + + # Create blocks in strict turn order: Skeptic 1, Believer 1, Skeptic 2, Believer 2, etc. + for turn in range(1, max_turn + 1): + # First Skeptic's turn + if turn in skeptic_turns and skeptic_turns[turn].strip(): + blocks.append({ + "name": f"{request.skeptic_voice_id}'s Turn {turn}", + "input": skeptic_turns[turn], + "silence_before": 1, + "voice_id": request.skeptic_voice_id, + "emotion": "neutral", + "model": "tts-1", + "speed": 1, + "duration": 0, + "type": "skeptic", + "turn": turn + }) + + # Then Believer's turn + if turn in believer_turns and believer_turns[turn].strip(): + blocks.append({ + "name": f"{request.believer_voice_id}'s Turn {turn}", + "input": believer_turns[turn], + "silence_before": 1, + "voice_id": request.believer_voice_id, + "emotion": "neutral", + "model": "tts-1", + "speed": 1, + "duration": 0, + "type": "believer", + "turn": turn + }) + + # Log the conversational structure for debugging + turn_structure = [f"{block.get('type', 'unknown')}-{block.get('turn', 'unknown')}" for block in blocks] + logger.info(f"Conversation structure: {turn_structure}") + + # Create podcast using TTS and store in MongoDB + logger.info("Starting podcast creation with TTS") + result = await podcast_manager.create_podcast( + topic=request.topic, + research=research_results, + conversation_blocks=blocks, + believer_voice_id=request.believer_voice_id, + skeptic_voice_id=request.skeptic_voice_id, + user_id=str(current_user["_id"]) + ) + + if "error" in result: + logger.error(f"Error in podcast creation: {result['error']}") + yield json.dumps({"type": "error", "content": result["error"]}) + "\n" + else: + logger.info(f"Podcast generated successfully with ID: {result.get('podcast_id')}") + # Create audio URL from the audio path + audio_url = f"/audio/{os.path.basename(os.path.dirname(result['audio_path']))}/final_podcast.mp3" + yield json.dumps({ + "type": "success", + "content": f"Podcast created successfully! ID: {result.get('podcast_id')}", + "podcast_url": audio_url + }) + "\n" + + except Exception as e: + logger.error(f"Error in streaming podcast generation: {str(e)}") + yield json.dumps({"type": "error", "content": str(e)}) + "\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream" + ) + +@app.get("/podcasts") +async def list_podcasts(current_user: dict = Depends(get_current_user)): + try: + # Query podcasts for the current user + cursor = podcasts.find({"user_id": str(current_user["_id"])}) + podcast_list = [] + async for podcast in cursor: + # Convert MongoDB _id to string and create audio URL + podcast["_id"] = str(podcast["_id"]) + if "audio_path" in podcast: + audio_url = f"/audio/{os.path.basename(os.path.dirname(podcast['audio_path']))}/final_podcast.mp3" + podcast["audio_url"] = f"http://localhost:8000{audio_url}" + podcast_list.append(podcast) + return podcast_list + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/podcasts/latest") +async def get_latest_podcast(current_user: dict = Depends(get_current_user)): + try: + # Query podcasts for the current user, sorted by creation date (newest first) + from bson.objectid import ObjectId + + # Find the most recent podcast for this user + latest_podcast = await podcasts.find_one( + {"user_id": str(current_user["_id"])}, + sort=[("created_at", -1)] # Sort by created_at in descending order + ) + + if not latest_podcast: + return {"message": "No podcasts found"} + + # Convert MongoDB _id to string and create audio URL + latest_podcast["_id"] = str(latest_podcast["_id"]) + + if "audio_path" in latest_podcast: + audio_url = f"/audio/{os.path.basename(os.path.dirname(latest_podcast['audio_path']))}/final_podcast.mp3" + latest_podcast["audio_url"] = f"http://localhost:8000{audio_url}" + + logger.info(f"Latest podcast found: {latest_podcast['topic']}") + return latest_podcast + except Exception as e: + logger.error(f"Error getting latest podcast: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.delete("/podcast/{podcast_id}") +async def delete_podcast(podcast_id: str, current_user: dict = Depends(get_current_user)): + try: + # Convert string ID to ObjectId + from bson.objectid import ObjectId + podcast_obj_id = ObjectId(podcast_id) + + # Find the podcast first to get its audio path + podcast = await podcasts.find_one({"_id": podcast_obj_id, "user_id": str(current_user["_id"])}) + if not podcast: + raise HTTPException(status_code=404, detail="Podcast not found") + + # Delete the podcast from MongoDB + result = await podcasts.delete_one({"_id": podcast_obj_id, "user_id": str(current_user["_id"])}) + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Podcast not found") + + # Delete the associated audio files if they exist + if "audio_path" in podcast: + audio_dir = os.path.dirname(podcast["audio_path"]) + if os.path.exists(audio_dir): + shutil.rmtree(audio_dir) + + return {"message": "Podcast deleted successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/agents/create", response_model=AgentResponse) +async def create_agent(agent: AgentCreate, current_user: dict = Depends(get_current_user)): + """Create a new agent configuration for the current user.""" + try: + # Convert the user ID to string to ensure consistent handling + user_id = str(current_user["_id"]) + + # Prepare agent data + agent_data = { + **agent.dict(), + "user_id": user_id, + "created_at": datetime.utcnow() + } + + # Insert the agent into the database + result = await agents.insert_one(agent_data) + + # Return the created agent with its ID + created_agent = await agents.find_one({"_id": result.inserted_id}) + if not created_agent: + raise HTTPException(status_code=500, detail="Failed to retrieve created agent") + + return { + "agent_id": str(created_agent["_id"]), + **{k: v for k, v in created_agent.items() if k != "_id"} + } + except Exception as e: + logger.error(f"Error creating agent: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") + +@app.get("/agents", response_model=List[AgentResponse]) +async def list_agents(current_user: dict = Depends(get_current_user)): + """List all agents created by the current user.""" + try: + # Convert user ID to string for consistent handling + user_id = str(current_user["_id"]) + user_agents = [] + + # Find agents for the current user + async for agent in agents.find({"user_id": user_id}): + user_agents.append({ + "agent_id": str(agent["_id"]), + **{k: v for k, v in agent.items() if k != "_id"} + }) + + return user_agents + except Exception as e: + logger.error(f"Error listing agents: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") + +@app.post("/agents/test-voice") +async def test_agent_voice(request: Request): + try: + # Parse request body + data = await request.json() + text = data.get("text") + voice_id = data.get("voice_id") + emotion = data.get("emotion", "neutral") # Default emotion + speed = data.get("speed", 1.0) + + # Log the received request + logger.info(f"Test voice request received: voice_id={voice_id}, text={text[:30]}...") + + if not text or not voice_id: + logger.error("Missing required fields in test voice request") + raise HTTPException(status_code=400, detail="Missing required fields (text or voice_id)") + + # Initialize the podcast manager + manager = PodcastManager() + + # Generate a unique filename for this test + test_filename = f"test_{voice_id}_{int(time.time())}.mp3" + output_dir = os.path.join("temp_audio", f"test_{int(time.time())}") + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, test_filename) + + logger.info(f"Generating test audio to {output_path}") + + # Generate the speech + success = manager.generate_speech(text, voice_id, output_path) + + if not success: + logger.error("Failed to generate test audio") + raise HTTPException(status_code=500, detail="Failed to generate test audio") + + # Construct the audio URL + audio_url = f"/audio/{os.path.basename(output_dir)}/{test_filename}" + full_audio_url = f"http://localhost:8000{audio_url}" + + logger.info(f"Test audio generated successfully at {full_audio_url}") + + # Return the full URL to the generated audio + return {"audio_url": full_audio_url, "status": "success"} + + except Exception as e: + logger.error(f"Error in test_agent_voice: {str(e)}", exc_info=True) + return {"error": str(e), "status": "error", "audio_url": None} + +# Add the new PUT endpoint for updating agents +@app.put("/agents/{agent_id}", response_model=AgentResponse) +async def update_agent(agent_id: str, agent: AgentCreate, current_user: dict = Depends(get_current_user)): + """Update an existing agent configuration.""" + try: + # Convert user ID to string for consistent handling + user_id = str(current_user["_id"]) + + # Convert agent_id to ObjectId + from bson.objectid import ObjectId + agent_obj_id = ObjectId(agent_id) + + # Check if agent exists and belongs to user + existing_agent = await agents.find_one({ + "_id": agent_obj_id, + "user_id": user_id + }) + + if not existing_agent: + raise HTTPException(status_code=404, detail="Agent not found or unauthorized") + + # Prepare update data + update_data = { + **agent.dict(), + "updated_at": datetime.utcnow() + } + + # Update the agent + result = await agents.update_one( + {"_id": agent_obj_id}, + {"$set": update_data} + ) + + if result.modified_count == 0: + raise HTTPException(status_code=500, detail="Failed to update agent") + + # Get the updated agent + updated_agent = await agents.find_one({"_id": agent_obj_id}) + if not updated_agent: + raise HTTPException(status_code=500, detail="Failed to retrieve updated agent") + + return { + "agent_id": str(updated_agent["_id"]), + **{k: v for k, v in updated_agent.items() if k != "_id"} + } + except Exception as e: + logger.error(f"Error updating agent: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") + +@app.get("/agents/{agent_id}", response_model=AgentResponse) +async def get_agent(agent_id: str, current_user: dict = Depends(get_current_user)): + """Get a specific agent by ID.""" + try: + # Convert user ID to string for consistent handling + user_id = str(current_user["_id"]) + + # Convert agent_id to ObjectId + from bson.objectid import ObjectId + agent_obj_id = ObjectId(agent_id) + + # Check if agent exists and belongs to user + agent = await agents.find_one({ + "_id": agent_obj_id, + "user_id": user_id + }) + + if not agent: + raise HTTPException(status_code=404, detail="Agent not found or unauthorized") + + # Return the agent data + return { + "agent_id": str(agent["_id"]), + **{k: v for k, v in agent.items() if k != "_id"} + } + except Exception as e: + logger.error(f"Error getting agent: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") + +@app.post("/generate-text-podcast", response_model=TextPodcastResponse) +async def generate_text_podcast(request: TextPodcastRequest, current_user: dict = Depends(get_current_user)): + """Generate a podcast from text input with a single voice and emotion.""" + logger.info(f"Received text-based podcast generation request from user: {current_user['username']}") + + try: + # Create conversation block for the single voice + conversation_blocks = [ + { + "name": "Voice", + "input": request.text, + "silence_before": 1, + "voice_id": request.voice_id, + "emotion": request.emotion, + "model": "tts-1", + "speed": request.speed, + "duration": 0 + } + ] + + # Use the provided title if available, otherwise use generic title + podcast_title = request.title if hasattr(request, 'title') and request.title else f"Text Podcast {datetime.now().strftime('%Y-%m-%d %H:%M')}" + podcast_description = request.text[:150] + "..." if len(request.text) > 150 else request.text + + # Create podcast using TTS + result = await podcast_manager.create_podcast( + topic=podcast_title, + research=podcast_description, + conversation_blocks=conversation_blocks, + believer_voice_id=request.voice_id, # Using same voice for both since we only need one + skeptic_voice_id=request.voice_id, + user_id=str(current_user["_id"]) + ) + + if "error" in result: + logger.error(f"Error in podcast creation: {result['error']}") + return TextPodcastResponse( + audio_url="", + status="failed", + error=result["error"], + duration=0, + updated_at=datetime.now().isoformat() + ) + + # Create audio URL from the audio path + audio_url = f"/audio/{os.path.basename(os.path.dirname(result['audio_path']))}/final_podcast.mp3" + full_audio_url = f"http://localhost:8000{audio_url}" + + logger.info("Successfully generated text-based podcast") + + return TextPodcastResponse( + audio_url=full_audio_url, + duration=result.get("duration", 0), + status="completed", + error=None, + updated_at=datetime.now().isoformat() + ) + + except Exception as e: + logger.error(f"Error generating text-based podcast: {str(e)}", exc_info=True) + return TextPodcastResponse( + audio_url="", + status="failed", + error=str(e), + duration=0, + updated_at=datetime.now().isoformat() + ) + + +@app.get("/api/workflows", response_model=List[WorkflowResponse]) +async def list_workflows(current_user: dict = Depends(get_current_user)): + try: + print("\n=== Debug list_workflows ===") + print(f"Current user object: {current_user}") + print(f"User ID type: {type(current_user['_id'])}") + print(f"Username: {current_user['username']}") + + # Use email as user_id for consistency + user_id = current_user["username"] + print(f"Using user_id (email): {user_id}") + + # Find workflows for this user and convert cursor to list + workflows_cursor = workflows.find({"user_id": user_id}) + workflows_list = await workflows_cursor.to_list(length=None) + + print(f"Found {len(workflows_list)} workflows") + + # Convert MongoDB _id to string and datetime to ISO format for each workflow + validated_workflows = [] + for workflow in workflows_list: + print(f"\nProcessing workflow: {workflow}") + + # Convert MongoDB _id to string + workflow_data = { + "id": str(workflow["_id"]), + "name": workflow["name"], + "description": workflow.get("description", ""), + "nodes": workflow.get("nodes", []), + "edges": workflow.get("edges", []), + "user_id": workflow["user_id"], + "created_at": workflow["created_at"].isoformat() if "created_at" in workflow else None, + "updated_at": workflow["updated_at"].isoformat() if "updated_at" in workflow else None + } + + print(f"Converted workflow data: {workflow_data}") + + # Validate each workflow + validated_workflow = WorkflowResponse(**workflow_data) + print(f"Validated workflow: {validated_workflow}") + + validated_workflows.append(validated_workflow) + + print(f"Successfully validated {len(validated_workflows)} workflows") + print("=== End Debug ===\n") + + return validated_workflows + except Exception as e: + print(f"Error in list_workflows: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.put("/api/workflows/{workflow_id}", response_model=WorkflowResponse) +async def update_workflow(workflow_id: str, workflow: WorkflowCreate, current_user: dict = Depends(get_current_user)): + """Update a specific workflow.""" + try: + print("\n=== Debug update_workflow ===") + print(f"Updating workflow ID: {workflow_id}") + print(f"Current user: {current_user.get('username')}") + + # Prepare update data + now = datetime.utcnow() + + # Convert insights to dict if it's a Pydantic model + insights_data = workflow.insights + if isinstance(insights_data, InsightsData): + insights_data = insights_data.dict() + print(f"Converted InsightsData to dict: {type(insights_data)}") + + workflow_data = { + "name": workflow.name, + "description": workflow.description, + "nodes": workflow.nodes, + "edges": workflow.edges, + "insights": insights_data, # Use the converted insights + "updated_at": now + } + + print(f"Update data prepared (insights type: {type(workflow_data['insights'])})") + + # Update the workflow + result = await workflows.update_one( + {"_id": ObjectId(workflow_id), "user_id": current_user.get("username")}, + {"$set": workflow_data} + ) + + if result.modified_count == 0: + raise HTTPException(status_code=404, detail="Workflow not found") + + # Get the updated workflow + updated_workflow = await workflows.find_one({"_id": ObjectId(workflow_id)}) + + # Prepare response data + response_data = { + "id": str(updated_workflow["_id"]), + "name": updated_workflow["name"], + "description": updated_workflow.get("description", ""), + "nodes": updated_workflow.get("nodes", []), + "edges": updated_workflow.get("edges", []), + "insights": updated_workflow.get("insights", ""), # Add insights field + "user_id": updated_workflow["user_id"], + "created_at": updated_workflow["created_at"].isoformat() if "created_at" in updated_workflow else None, + "updated_at": updated_workflow["updated_at"].isoformat() if "updated_at" in updated_workflow else None + } + + print(f"Response data prepared (insights type: {type(response_data['insights'])})") + + # Create and validate the response model + response = WorkflowResponse(**response_data) + print(f"Validated response: {response}") + print("=== End Debug ===\n") + + return response + except Exception as e: + print(f"Error in update_workflow: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.delete("/api/workflows/{workflow_id}") +async def delete_workflow(workflow_id: str, current_user: dict = Depends(get_current_user)): + """Delete a specific workflow.""" + try: + result = await workflows.delete_one({ + "_id": ObjectId(workflow_id), + "user_id": current_user.get("username") # This is actually the email from the token + }) + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Workflow not found") + + return {"message": "Workflow deleted successfully"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/api/workflows", response_model=WorkflowResponse) +async def create_workflow(workflow: WorkflowCreate, current_user: dict = Depends(get_current_user)): + try: + print("\n=== Debug create_workflow ===") + print(f"Current user object: {current_user}") + print(f"Username: {current_user.get('username')}") + + # Use email from token as user_id for consistency + user_id = current_user.get("username") # This is actually the email from the token + print(f"Using user_id (email): {user_id}") + + # Convert insights to dict if it's a Pydantic model + insights_data = workflow.insights + if isinstance(insights_data, InsightsData): + insights_data = insights_data.dict() + print(f"Converted InsightsData to dict: {type(insights_data)}") + + # Create workflow data + now = datetime.utcnow() + workflow_data = { + "name": workflow.name, + "description": workflow.description, + "nodes": workflow.nodes, + "edges": workflow.edges, + "insights": insights_data, # Use the converted insights + "user_id": user_id, + "created_at": now, + "updated_at": now + } + + print(f"Workflow data prepared (insights type: {type(workflow_data['insights'])})") + + # Insert into database + result = await workflows.insert_one(workflow_data) + + # Prepare response data + response_data = { + "id": str(result.inserted_id), + "name": workflow_data["name"], + "description": workflow_data["description"], + "nodes": workflow_data["nodes"], + "edges": workflow_data["edges"], + "insights": workflow_data.get("insights"), # Add insights field + "user_id": workflow_data["user_id"], + "created_at": workflow_data["created_at"].isoformat(), + "updated_at": workflow_data["updated_at"].isoformat() + } + + print(f"Response data prepared (insights type: {type(response_data['insights'])})") + + # Create and validate the response model + response = WorkflowResponse(**response_data) + print(f"Validated response: {response}") + print("=== End Debug ===\n") + + return response + except Exception as e: + print(f"Error in create_workflow: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/api/workflows/{workflow_id}", response_model=WorkflowResponse) +async def get_workflow(workflow_id: str, current_user: dict = Depends(get_current_user)): + """Get a specific workflow by ID.""" + try: + print("\n=== Debug get_workflow ===") + print(f"Looking for workflow ID: {workflow_id}") + print(f"Current user: {current_user.get('username')}") + + workflow = await workflows.find_one({ + "_id": ObjectId(workflow_id), + "user_id": current_user.get("username") # This is actually the email from the token + }) + + if workflow is None: + raise HTTPException(status_code=404, detail="Workflow not found") + + print(f"Found workflow: {workflow}") + + # Convert MongoDB _id to string + workflow["id"] = str(workflow.pop("_id")) + + # Convert datetime objects to ISO format strings + if "created_at" in workflow: + workflow["created_at"] = workflow["created_at"].isoformat() + print(f"Converted created_at: {workflow['created_at']}") + + if "updated_at" in workflow: + workflow["updated_at"] = workflow["updated_at"].isoformat() + print(f"Converted updated_at: {workflow['updated_at']}") + + # Ensure all required fields are present + response_data = { + "id": workflow["id"], + "name": workflow["name"], + "description": workflow.get("description", ""), + "nodes": workflow.get("nodes", []), + "edges": workflow.get("edges", []), + "insights": workflow.get("insights", ""), # Add insights field + "user_id": workflow["user_id"], + "created_at": workflow.get("created_at"), + "updated_at": workflow.get("updated_at") + } + + print(f"Response data: {response_data}") + + # Create and validate the response model + response = WorkflowResponse(**response_data) + print(f"Validated response: {response}") + print("=== End Debug ===\n") + + return response + except Exception as e: + logger.error(f"Error in get_workflow: {str(e)}") + print(f"Error in get_workflow: {str(e)}") + print(f"Error type: {type(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.post("/direct-podcast", response_model=TextPodcastResponse) +async def create_direct_podcast(request: Request, current_user: dict = Depends(get_current_user)): + """Generate a podcast directly from conversation blocks with different voices.""" + logger.info(f"Received direct podcast generation request from user: {current_user['username']}") + + try: + # Parse the request body + data = await request.json() + topic = data.get("topic", "Debate") + conversation_blocks = data.get("conversation_blocks", []) + + logger.info(f"Direct podcast request for topic: {topic}") + logger.info(f"Number of conversation blocks: {len(conversation_blocks)}") + + if not conversation_blocks: + raise HTTPException(status_code=400, detail="No conversation blocks provided") + + # Format conversation blocks for the podcast manager + formatted_blocks = [] + for idx, block in enumerate(conversation_blocks): + # Extract data from each block + content = block.get("content", "") + voice_id = block.get("voice_id", "alloy") # Default to alloy if not specified + block_type = block.get("type", "generic") + turn = block.get("turn", idx + 1) + agent_id = block.get("agent_id", "") + + # Format for podcast manager + formatted_block = { + "name": f"Turn {turn}", + "input": content, + "silence_before": 0.3, # Short pause between blocks + "voice_id": voice_id, + "emotion": "neutral", + "model": "tts-1", + "speed": 1.0, + "duration": 0, + "type": block_type, + "turn": turn, + "agent_id": agent_id + } + + formatted_blocks.append(formatted_block) + + # Use the podcast manager to create the audio + result = await podcast_manager.create_podcast( + topic=topic, + research=f"Direct podcast on {topic}", + conversation_blocks=formatted_blocks, + believer_voice_id="alloy", # These are just placeholders for the manager + skeptic_voice_id="echo", + user_id=str(current_user["_id"]) + ) + + if "error" in result: + logger.error(f"Error in direct podcast creation: {result['error']}") + return TextPodcastResponse( + audio_url="", + status="failed", + error=result["error"], + duration=0, + updated_at=datetime.now().isoformat() + ) + + # Create audio URL from the audio path + audio_url = f"/audio/{os.path.basename(os.path.dirname(result['audio_path']))}/final_podcast.mp3" + full_audio_url = f"http://localhost:8000{audio_url}" + + logger.info(f"Successfully generated direct podcast: {result.get('podcast_id')}") + + return TextPodcastResponse( + audio_url=full_audio_url, + duration=result.get("duration", 0), + status="completed", + error=None, + updated_at=datetime.now().isoformat() + ) + + except Exception as e: + logger.error(f"Error generating direct podcast: {str(e)}", exc_info=True) + return TextPodcastResponse( + audio_url="", + status="failed", + error=str(e), + duration=0, + updated_at=datetime.now().isoformat() + ) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000000000000000000000000000000000000..28b98dad33d7f70f010c7a177b54270eb7e72444 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,114 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Union, Any +from datetime import datetime + +class UserCreate(BaseModel): + username: str + password: str + +class UserLogin(BaseModel): + username: str + password: str + +class Token(BaseModel): + access_token: str + token_type: str + +class UserUpdate(BaseModel): + password: str + +class UserResponse(BaseModel): + username: str + +class AgentCreate(BaseModel): + name: str + voice_id: str + voice_name: str + voice_description: str + speed: float + pitch: float + volume: float + output_format: str + personality: str = None # Optional field for agent personality + +class AgentResponse(BaseModel): + agent_id: str + name: str + voice_id: str + voice_name: str + voice_description: str + speed: float + pitch: float + volume: float + output_format: str + user_id: str + personality: str = None # Optional field for agent personality + +class PodcastRequest(BaseModel): + topic: str + believer_voice_id: str + skeptic_voice_id: str + +class ConversationBlock(BaseModel): + name: str + input: str + silence_before: int + voice_id: str + emotion: str + model: str + speed: float + duration: int + +class PodcastResponse(BaseModel): + podcast_id: str + audio_url: Optional[str] + topic: str + error: Optional[str] + +# Models for structured debate transcript and insights +class TranscriptEntry(BaseModel): + agentId: str + agentName: str + turn: int + content: str + +class InsightsData(BaseModel): + topic: str + research: str + transcript: List[TranscriptEntry] + keyInsights: List[str] + conclusion: str + +# New Workflow Models +class WorkflowCreate(BaseModel): + name: str + description: str + nodes: List[Dict] + edges: List[Dict] + insights: Optional[Union[InsightsData, str]] = None + +class WorkflowResponse(BaseModel): + id: str + name: str + description: str + nodes: List[Dict] + edges: List[Dict] + insights: Optional[Union[InsightsData, str]] = None + user_id: str + created_at: Optional[str] + updated_at: Optional[str] + +class TextPodcastRequest(BaseModel): + text: str + voice_id: str = "alloy" + emotion: str = "neutral" + speed: float = 1.0 + title: Optional[str] = None + +class TextPodcastResponse(BaseModel): + audio_url: str + duration: Optional[float] + status: str + error: Optional[str] + updated_at: Optional[str] + diff --git a/backend/app/routers/__pycache__/podcast.cpython-311.pyc b/backend/app/routers/__pycache__/podcast.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..294dd711f2c7a02673577f2a6b51d6d8130001a9 Binary files /dev/null and b/backend/app/routers/__pycache__/podcast.cpython-311.pyc differ diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..47508a1d64d76459974797406846b134c679e2c6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +motor==3.1.1 +pymongo==4.3.3 +certifi==2024.2.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +python-decouple==3.8 +langgraph==0.2.14 +langchain>=0.1.0 +langchain-openai>=0.0.5 +langchain-core>=0.2.35 +langchain-community>=0.0.24 +pydub==0.25.1 \ No newline at end of file diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000000000000000000000000000000000000..902646eaf81c4654a59efafcc262b2633cd146f6 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,14 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + reload_dirs=["app"], + workers=1, + ws_ping_interval=None, + ws_ping_timeout=None, + timeout_keep_alive=0 + ) \ No newline at end of file diff --git a/backend/temp_audio/Default/final_podcast.mp3 b/backend/temp_audio/Default/final_podcast.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..18b2523afe5d233f1f25d109c4f0f38ce768041a --- /dev/null +++ b/backend/temp_audio/Default/final_podcast.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:266ec16b7f8b53b12e8ac9899be9932265a8723abb61c57915fb9ae64438b6fc +size 408620 diff --git a/build_and_deploy.sh b/build_and_deploy.sh new file mode 100644 index 0000000000000000000000000000000000000000..9fdc6ff33559f572c5dd26781219ec8174d70aa5 --- /dev/null +++ b/build_and_deploy.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +echo "===> Building PodCraft for deployment <====" + +# Navigate to frontend directory +echo "Building frontend..." +cd frontend/podcraft + +# Install dependencies +echo "Installing frontend dependencies..." +npm install + +# Build the frontend +echo "Creating production build..." +npm run build + +# Back to root directory +cd ../../ + +# Make sure static directory exists +mkdir -p app/static + +# Copy build to static directory (this will be mounted by FastAPI) +echo "Copying frontend build to static directory..." +cp -r frontend/podcraft/build/* app/static/ + +echo "Building Docker image..." +docker build -t podcraft-app . + +echo "Build completed successfully!" +echo "You can now run: docker run -p 8000:8000 podcraft-app" \ No newline at end of file diff --git a/build_for_spaces.sh b/build_for_spaces.sh new file mode 100644 index 0000000000000000000000000000000000000000..bde81cc80dd44b3cd2559a013e436535e5084750 --- /dev/null +++ b/build_for_spaces.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +echo "===> Building PodCraft for HuggingFace Spaces <====" + +# Navigate to frontend directory +echo "Building frontend..." +cd frontend/podcraft + +# Install dependencies +echo "Installing frontend dependencies..." +npm install + +# Build the frontend +echo "Creating production build..." +npm run build + +# Back to root directory +cd ../../ + +# Make sure static directory exists +mkdir -p app/static + +# Copy build to static directory (this will be mounted by FastAPI) +echo "Copying frontend build to static directory..." +cp -r frontend/podcraft/build/* app/static/ + +# Copy the Spaces Dockerfile to main Dockerfile +echo "Setting up Dockerfile for Spaces deployment..." +cp Dockerfile.spaces Dockerfile + +echo "Build setup completed successfully!" +echo "You can now push this to your HuggingFace Space repository" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..4d942f85b4458a71da21e97821afe03da3217b9b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3' + +services: + podcraft-app: + build: . + ports: + - "8000:8000" + volumes: + - ./app:/app/app + - ./temp_audio:/app/temp_audio + environment: + - MONGODB_URL=mongodb+srv://NageshBM:Nash166^@podcraft.ozqmc.mongodb.net/?retryWrites=true&w=majority&appName=Podcraft + - SECRET_KEY=your-secret-key-change-in-production + - OPENAI_API_KEY=${OPENAI_API_KEY} + restart: always diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..aba25f73589f9ec9960ca84837c1236eebfec363 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "frontend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/frontend/podcraft/.gitignore b/frontend/podcraft/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/frontend/podcraft/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/podcraft/README.md b/frontend/podcraft/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fd3b758d9c5bdd978006b8429e0caaa761644749 --- /dev/null +++ b/frontend/podcraft/README.md @@ -0,0 +1,12 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/podcraft/assets/bg.gif b/frontend/podcraft/assets/bg.gif new file mode 100644 index 0000000000000000000000000000000000000000..a00efc31bce0298e8590bda545ee5f2f4a69adc8 --- /dev/null +++ b/frontend/podcraft/assets/bg.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d48e5a618e4a404b15baa881dfbb687da0e4fe9564dbb6cd5dd3d984ef08910 +size 1056421 diff --git a/frontend/podcraft/assets/bg2.gif b/frontend/podcraft/assets/bg2.gif new file mode 100644 index 0000000000000000000000000000000000000000..61a120f8107897814f79058bc9204ea770bba3a2 --- /dev/null +++ b/frontend/podcraft/assets/bg2.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b9d6681b74f7e1451bcaaa41de66b55fa9cebe6bec379025186637e16bd8b27 +size 9858301 diff --git a/frontend/podcraft/assets/bg3.gif b/frontend/podcraft/assets/bg3.gif new file mode 100644 index 0000000000000000000000000000000000000000..544dc6589442c383831bab55f5eba9cde89ca5ef --- /dev/null +++ b/frontend/podcraft/assets/bg3.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1cd5c03f9b78e517532e35c866d2627ec96e56935ba1b62c4e8fb845ed1e74c +size 7655809 diff --git a/frontend/podcraft/eslint.config.js b/frontend/podcraft/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ec2b712d301e7ddeca8327fc4b967069cc82047b --- /dev/null +++ b/frontend/podcraft/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/frontend/podcraft/index.html b/frontend/podcraft/index.html new file mode 100644 index 0000000000000000000000000000000000000000..0c589eccd4d48e270e161a1ab91baee5e5f4b4bc --- /dev/null +++ b/frontend/podcraft/index.html @@ -0,0 +1,13 @@ + + + + + + +One prompt
+ {isLogin ? "Don't have an account? " : "Already have an account? "} + + {isLogin ? 'Sign Up' : 'Login'} + +
+This will permanently delete "{podcastName}" podcast.
+This action cannot be undone.
+{nodeType.description}
+Username: {user.username}
+ + {message &&{message}
} +${insightsData.conclusion}
+We encountered a problem while generating insights. Please try again.
+No transcript data available.
+ )} +No key insights available.
+ )} +{conclusion}
+Design and configure AI agents with unique personalities and roles for your podcast
+Executing workflow... Please wait.
+ +{workflowInsights}+
+ Workflow insights are now active. You'll see real-time analytics and insights about your workflow here. +
+ ) : ( +Design and configure AI agents with unique personalities and roles for your podcast
+Creating your demo podcast...
+Click here to generate a demo podcast
+Insights from the AI agents will appear here
+{step}
+{response}
+{believerResponses[index]}
+{successMessage}
++ {!believerResponses.length ? "Researching topic..." : + !skepticResponses.length ? "Generating believer's perspective..." : + skepticResponses.length > 0 && !isSuccess ? "Creating podcast with TTS..." : + ""} +
+You haven't generated any podcasts yet. Head over to the Home page to create your first podcast!
+{podcast.research ? podcast.research.substring(0, 150) + '...' : 'No description available'}
+Manage and edit your podcast workflow templates
+You haven't created any workflows yet. Click the "New Workflow" button to get started!
+ +{workflow.description || 'No description available'}
+ +