Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -7,54 +7,238 @@ Analysis endpoint for podcast ideas.
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
import json
import uuid
from sqlalchemy.orm import Session
from services.database import get_db
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.llm_providers.main_text_generation import llm_text_gen
from services.llm_providers.main_image_generation import generate_image
from services.podcast_bible_service import PodcastBibleService
from utils.asset_tracker import save_asset_to_library
from loguru import logger
from ..models import PodcastAnalyzeRequest, PodcastAnalyzeResponse
from ..constants import PODCAST_IMAGES_DIR
from ..models import (
PodcastAnalyzeRequest,
PodcastAnalyzeResponse,
PodcastEnhanceIdeaRequest,
PodcastEnhanceIdeaResponse
)
router = APIRouter()
@router.post("/idea/enhance", response_model=PodcastEnhanceIdeaResponse)
async def enhance_podcast_idea(
request: PodcastEnhanceIdeaRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Take raw keywords/topic and use AI to craft a presentable, detailed podcast idea.
Uses the user's Podcast Bible for hyper-personalization if available.
"""
user_id = require_authenticated_user(current_user)
# Serialize Bible context if provided or generate from onboarding
bible_context = ""
try:
bible_service = PodcastBibleService()
if request.bible:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
else:
# Generate from onboarding data directly
bible_obj = bible_service.generate_bible(user_id, "temp_enhance")
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
prompt = f"""
You are a creative podcast producer. Your goal is to take a simple podcast idea or keywords
and transform it into a compelling, professional, and detailed episode concept.
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
RAW IDEA/KEYWORDS: "{request.idea}"
TASK:
1. Rewrite the idea into a professional, presentable 2-3 sentence episode pitch.
2. Focus on making it sound expert-led and audience-focused.
3. Ensure it aligns with the host's persona and target audience interests if context was provided.
4. Keep it concise but information-rich.
Return JSON with:
- enhanced_idea: the rewritten, professional episode pitch
- rationale: 1 sentence explaining why this version works better for the target audience
"""
try:
raw = llm_text_gen(prompt=prompt, user_id=user_id, json_struct=None)
# Normalize response
if isinstance(raw, str):
data = json.loads(raw)
else:
data = raw
return PodcastEnhanceIdeaResponse(
enhanced_idea=data.get("enhanced_idea", request.idea),
rationale=data.get("rationale", "Made it more professional and listener-focused.")
)
except Exception as exc:
logger.error(f"[Podcast Enhance] Failed for user {user_id}: {exc}")
return PodcastEnhanceIdeaResponse(
enhanced_idea=request.idea,
rationale="Failed to enhance idea with AI, using original."
)
@router.post("/analyze", response_model=PodcastAnalyzeResponse)
async def analyze_podcast_idea(
request: PodcastAnalyzeRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""
Analyze a podcast idea and return podcast-oriented outlines, keywords, and titles.
This uses the shared LLM provider but with a podcast-specific prompt (not story format).
If no avatar_url is provided, it generates one automatically based on the host's look.
"""
user_id = require_authenticated_user(current_user)
# Serialize Bible context if provided or generate from onboarding
bible_context = ""
bible_obj = None
try:
bible_service = PodcastBibleService()
if request.bible:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
bible_obj = bible_data
else:
# Generate from onboarding data directly
bible_obj = bible_service.generate_bible(user_id, "temp_analyze")
bible_context = bible_service.serialize_bible(bible_obj)
bible_obj = bible_obj
except Exception as exc:
logger.warning(f"[Podcast Analyze] Failed to parse or generate bible context: {exc}")
# --- NEW: Generate Presenter Avatar if missing ---
final_avatar_url = request.avatar_url
final_avatar_prompt = None
if not final_avatar_url:
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
try:
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
from services.subscription import PricingService
from services.subscription.preflight_validator import validate_image_generation_operations
pricing_service = PricingService(db)
validate_image_generation_operations(
pricing_service=pricing_service,
user_id=user_id,
num_images=1
)
# 2. Build avatar prompt from Bible host look or fallback
host_look = bible_obj.host.look if bible_obj and bible_obj.host.look else "A professional podcast host"
visual_style = bible_obj.visual_style.style_preset if bible_obj else "Realistic Photography"
final_avatar_prompt = f"Professional headshot of a podcast host, {host_look}, {visual_style} style, clean background, soft studio lighting, center-focused, high resolution, sharp focus, professional photography quality, 16:9 aspect ratio."
# 3. Generate the image
logger.info(f"[Podcast Analyze] Generating avatar with prompt: {final_avatar_prompt}")
image_result = generate_image(
prompt=final_avatar_prompt,
user_id=user_id,
width=1024,
height=1024
)
# 4. Save to disk and library
if image_result and image_result.image_bytes:
img_id = str(uuid.uuid4())[:8]
filename = f"presenter_podcast_{user_id}_{img_id}.png"
output_path = PODCAST_IMAGES_DIR / filename
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(image_result.image_bytes)
final_avatar_url = f"/api/podcast/images/avatars/{filename}"
# Save to asset library for reuse
save_asset_to_library(
db=db,
user_id=user_id,
asset_type="image",
file_url=final_avatar_url,
filename=filename,
title=f"Presenter Avatar - {request.idea[:40]}",
description=f"AI-generated podcast presenter for: {request.idea}",
provider=image_result.provider,
model=image_result.model,
cost=image_result.cost
)
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
except Exception as e:
logger.error(f"[Podcast Analyze] ❌ Failed to generate avatar: {e}")
# Non-fatal: continue analysis even if avatar generation fails
# --- END: Avatar Generation ---
# Incorporate user feedback if provided
feedback_context = ""
if request.feedback:
feedback_context = f"""
USER REGENERATION FEEDBACK:
The user was not satisfied with the previous analysis. They provided the following instructions for improvement:
"{request.feedback}"
Please prioritize this feedback and adjust the analysis accordingly.
"""
prompt = f"""
You are an expert podcast producer. Given a podcast idea, craft concise podcast-ready assets
You are an expert podcast producer and research strategist. Given a podcast idea, craft concise podcast-ready assets
that sound like episode plans (not fiction stories).
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
{feedback_context}
Podcast Idea: "{request.idea}"
Duration: ~{request.duration} minutes
Speakers: {request.speakers} (host + optional guest)
TASK:
1. Define the target audience and content type aligned with the Bible's "Audience DNA" and "Brand DNA".
2. Identify 5 high-impact keywords.
3. Propose 2 episode outlines with factual segments.
4. Suggest 3 titles.
5. IMPORTANT: Generate 4-6 specific research queries for Exa. These queries MUST be highly targeted to the episode's topic, the host's expertise level, and the audience's interests as defined in the Bible.
* Do NOT use generic queries like "latest trends in X".
* DO use queries that look for case studies, specific data points, expert opinions, or contrasting viewpoints that would make for a deep, insightful podcast conversation.
Return JSON with:
- audience: short target audience description
- content_type: podcast style/format
- top_keywords: 5 podcast-relevant keywords/phrases
- suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual)
- title_suggestions: 3 concise episode titles (no cliffhanger storytelling)
- exa_suggested_config: suggested Exa search options to power research (keep conservative defaults to control cost), with:
- exa_search_type: "auto" | "neural" | "keyword" (prefer "auto" unless clearly news-heavy)
- title_suggestions: 3 concise episode titles
- research_queries: array of {{"query": "string", "rationale": "string"}}
- exa_suggested_config: suggested Exa search options with:
- exa_search_type: "auto" | "neural" | "keyword"
- exa_category: one of ["research paper","news","company","github","tweet","personal site","pdf","financial report","linkedin profile"]
- exa_include_domains: up to 3 reputable domains to prioritize (optional)
- exa_exclude_domains: up to 3 domains to avoid (optional)
- exa_include_domains: up to 3 reputable domains
- exa_exclude_domains: up to 3 domains
- max_sources: 6-10
- include_statistics: boolean (true if topic needs fresh stats)
- date_range: one of ["last_month","last_3_months","last_year","all_time"] (pick recent if time-sensitive)
- include_statistics: boolean
- date_range: one of ["last_month","last_3_months","last_year","all_time"]
Requirements:
- Keep language factual, actionable, and suited for spoken audio.
- Avoid narrative fiction tone; focus on insights, hooks, objections, and takeaways.
- Prefer 2024-2025 context when relevant.
- Avoid narrative fiction tone.
- Prefer 2024-2025 context.
"""
try:
@@ -82,7 +266,7 @@ Requirements:
top_keywords = data.get("top_keywords") or []
suggested_outlines = data.get("suggested_outlines") or []
title_suggestions = data.get("title_suggestions") or []
research_queries = data.get("research_queries") or []
exa_suggested_config = data.get("exa_suggested_config") or None
return PodcastAnalyzeResponse(
@@ -91,6 +275,10 @@ Requirements:
top_keywords=top_keywords,
suggested_outlines=suggested_outlines,
title_suggestions=title_suggestions,
research_queries=research_queries,
exa_suggested_config=exa_suggested_config,
bible=bible_obj.model_dump() if bible_obj else None,
avatar_url=final_avatar_url,
avatar_prompt=final_avatar_prompt,
)

View File

@@ -86,6 +86,19 @@ async def generate_podcast_scene_image(
logger.info(f"[Podcast] No base avatar URL provided, will generate from scratch")
base_avatar_bytes = None
# Extract Podcast Bible context for hyper-personalization
bible_context = ""
bible_obj = None
if request.bible:
try:
from services.podcast_bible_service import PodcastBibleService
from models.podcast_bible_models import PodcastBible
bible_service = PodcastBibleService()
bible_obj = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"[Podcast Image] Failed to serialize podcast bible: {exc}")
# Build optimized prompt for scene image generation
# When base avatar is provided, use Ideogram Character to maintain consistency
# Otherwise, generate from scratch with podcast-optimized prompt
@@ -106,6 +119,14 @@ async def generate_podcast_scene_image(
if request.scene_title:
prompt_parts.append(f"Scene: {request.scene_title}")
# Use Bible visual style if available
if bible_obj:
prompt_parts.append(f"Style: {bible_obj.visual_style.style_preset}")
prompt_parts.append(f"Environment: {bible_obj.visual_style.environment}")
prompt_parts.append(f"Lighting: {bible_obj.visual_style.lighting}")
if bible_obj.host.look:
prompt_parts.append(f"Host Look: {bible_obj.host.look}")
# Scene content insights for visual context
if request.scene_content:
content_preview = request.scene_content[:200].replace("\n", " ").strip()
@@ -127,12 +148,14 @@ async def generate_podcast_scene_image(
prompt_parts.append(f"Topic: {idea_preview}")
# Studio setting (maintains podcast aesthetic)
prompt_parts.extend([
"Professional podcast recording studio",
"Modern microphone setup",
"Clean background, professional lighting",
"16:9 aspect ratio, video-optimized composition"
])
if not bible_obj:
prompt_parts.extend([
"Professional podcast recording studio",
"Modern microphone setup",
"Clean background, professional lighting"
])
prompt_parts.append("16:9 aspect ratio, video-optimized composition")
image_prompt = ", ".join(prompt_parts)
@@ -221,14 +244,22 @@ async def generate_podcast_scene_image(
# Standard generation from scratch (no base avatar provided)
prompt_parts = []
# Core podcast studio elements
prompt_parts.extend([
"Professional podcast recording studio",
"Modern podcast setup with high-quality microphone",
"Clean, minimalist background suitable for video",
"Professional studio lighting with soft, even illumination",
"Podcast host environment, professional and inviting"
])
# Use Bible visual style if available
if bible_obj:
prompt_parts.append(f"Style: {bible_obj.visual_style.style_preset}")
prompt_parts.append(f"Environment: {bible_obj.visual_style.environment}")
prompt_parts.append(f"Lighting: {bible_obj.visual_style.lighting}")
if bible_obj.host.look:
prompt_parts.append(f"Host Look: {bible_obj.host.look}")
else:
# Core podcast studio elements
prompt_parts.extend([
"Professional podcast recording studio",
"Modern podcast setup with high-quality microphone",
"Clean, minimalist background suitable for video",
"Professional studio lighting with soft, even illumination",
"Podcast host environment, professional and inviting"
])
# Scene-specific context
if request.scene_title:
@@ -264,12 +295,13 @@ async def generate_podcast_scene_image(
])
# Style constraints
prompt_parts.extend([
"Realistic photography style, not illustration or cartoon",
"Professional broadcast quality",
"Warm, inviting atmosphere",
"Clean composition with breathing room for avatar placement"
])
if not bible_obj:
prompt_parts.extend([
"Realistic photography style, not illustration or cartoon",
"Professional broadcast quality",
"Warm, inviting atmosphere",
"Clean composition with breathing room for avatar placement"
])
image_prompt = ", ".join(prompt_parts)

View File

@@ -47,6 +47,7 @@ async def create_project(
duration=request.duration,
speakers=request.speakers,
budget_cap=request.budget_cap,
avatar_url=request.avatar_url,
)
return PodcastProjectResponse.model_validate(project)

View File

@@ -1,22 +1,26 @@
"""
Podcast Research Handlers
Research endpoints using Exa provider.
Research endpoints using Exa provider and LLM summarization.
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from typing import Dict, Any, List
from types import SimpleNamespace
import json
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.blog_writer.research.exa_provider import ExaResearchProvider
from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService
from loguru import logger
from ..models import (
PodcastExaResearchRequest,
PodcastExaResearchResponse,
PodcastExaSource,
PodcastExaConfig,
PodcastResearchInsight,
)
router = APIRouter()
@@ -28,7 +32,8 @@ async def podcast_research_exa(
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Run podcast research directly via Exa (no blog writer pipeline).
Run podcast research via Exa and then use LLM to extract deep insights.
Uses Podcast Bible and Analysis context for hyper-personalization.
"""
user_id = require_authenticated_user(current_user)
@@ -47,22 +52,121 @@ async def podcast_research_exa(
)
provider = ExaResearchProvider()
prompt = request.topic
# --- Context Building ---
bible_service = PodcastBibleService()
bible_context = ""
if request.bible:
try:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
except Exception as exc:
logger.warning(f"[Podcast Research] Failed to serialize bible: {exc}")
analysis_context = ""
if request.analysis:
analysis_context = f"""
PODCAST ANALYSIS CONTEXT:
Audience: {request.analysis.get('audience', 'General')}
Content Type: {request.analysis.get('content_type', 'Informative')}
Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
"""
# Exa search params
industry = request.bible.get("brand", {}).get("industry", "") if request.bible else ""
target_audience = ""
if request.bible:
audience_dna = request.bible.get("audience", {})
if audience_dna:
interests = ", ".join(audience_dna.get("interests", []))
target_audience = f"Expertise: {audience_dna.get('expertise_level', '')}. Interests: {interests}."
try:
# 1. RUN EXA SEARCH
result = await provider.search(
prompt=prompt,
prompt=request.topic,
topic=request.topic,
industry="",
target_audience="",
industry=industry,
target_audience=target_audience,
config=cfg,
user_id=user_id,
)
except Exception as exc:
logger.error(f"[Podcast Exa Research] Failed for user {user_id}: {exc}")
logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}")
raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}")
# Track usage if available
# 2. EXTRACT INSIGHTS VIA LLM
raw_content = result.get("content", "")
sources = result.get("sources", [])
summary = ""
key_insights = []
if raw_content and sources:
logger.info(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
prompt = f"""
You are an expert research analyst for a high-end podcast production team.
Your task is to analyze the following research data and extract deep, actionable insights for a podcast episode.
PODCAST CONTEXT:
Topic: {request.topic}
{bible_context}
{analysis_context}
RESEARCH DATA (from {len(sources)} sources):
{raw_content}
TASK:
1. Provide a comprehensive summary (2-3 paragraphs) of the most important findings. Use Markdown for formatting (bolding, lists).
2. Extract 3-5 "Key Insights". Each insight should have a title and a detailed explanation.
3. For each insight, identify which source indices (e.g. 1, 2) it was derived from.
NOTE: The research data includes "Key Highlights", "Summaries", and "Excerpts" from various sources.
Pay special attention to the "Key Highlights" sections as they contain the most relevant information extracted by the neural search engine.
Return JSON structure:
{{
"summary": "Detailed markdown summary...",
"key_insights": [
{{
"title": "Insight Title",
"content": "Detailed markdown content...",
"source_indices": [1, 2]
}}
]
}}
Requirements:
- Ensure insights are deep, not just superficial facts. Look for trends, expert opinions, and specific data points.
- Tone should be professional, insightful, and ready for a podcast host to discuss.
- Avoid generic filler.
"""
try:
llm_response = llm_text_gen(prompt=prompt, user_id=user_id, json_struct=None)
# Normalize response
if isinstance(llm_response, str):
data = json.loads(llm_response)
else:
data = llm_response
summary = data.get("summary", "")
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
except Exception as exc:
logger.error(f"[Podcast Research] LLM Insight extraction failed: {exc}")
# Fallback to a basic summary if LLM fails
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
# Fallback: if summary is still empty (e.g. LLM returned empty string), use raw content first paragraph or basic text
if not summary:
if raw_content:
summary = raw_content[:2000] # Use first 2000 chars of raw content as summary
else:
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
# 3. TRACK USAGE
try:
cost_total = 0.0
if isinstance(result, dict):
@@ -72,28 +176,31 @@ async def podcast_research_exa(
logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}")
sources_payload = []
if isinstance(result, dict):
for src in result.get("sources", []) or []:
try:
sources_payload.append(PodcastExaSource(**src))
except Exception:
sources_payload.append(PodcastExaSource(**{
"title": src.get("title", ""),
"url": src.get("url", ""),
"excerpt": src.get("excerpt", ""),
"published_at": src.get("published_at"),
"highlights": src.get("highlights"),
"summary": src.get("summary"),
"source_type": src.get("source_type"),
"index": src.get("index"),
}))
for src in sources:
try:
sources_payload.append(PodcastExaSource(**src))
except Exception:
sources_payload.append(PodcastExaSource(**{
"title": src.get("title", ""),
"url": src.get("url", ""),
"excerpt": src.get("excerpt", ""),
"published_at": src.get("published_at"),
"highlights": src.get("highlights"),
"summary": src.get("summary"),
"source_type": src.get("source_type"),
"index": src.get("index"),
"image": src.get("image"),
"author": src.get("author"),
}))
return PodcastExaResearchResponse(
sources=sources_payload,
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
summary=summary,
key_insights=key_insights,
cost=result.get("cost") if isinstance(result, dict) else None,
search_type=result.get("search_type") if isinstance(result, dict) else None,
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
content=result.get("content") if isinstance(result, dict) else None,
content=raw_content,
)

View File

@@ -11,6 +11,8 @@ import json
from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user
from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService
from models.podcast_bible_models import PodcastBible
from loguru import logger
from ..models import (
PodcastScriptRequest,
@@ -62,8 +64,39 @@ async def generate_podcast_script(
logger.warning(f"Failed to parse research context: {exc}")
research_context = ""
# Extract Podcast Bible context for hyper-personalization
bible_context = ""
if request.bible:
try:
bible_service = PodcastBibleService()
bible_obj = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"Failed to serialize podcast bible: {exc}")
# Extract Analysis and Outline context for grounding
analysis_context = ""
if request.analysis:
analysis_context = f"""
TARGET AUDIENCE: {request.analysis.get('audience', 'General')}
CONTENT TYPE: {request.analysis.get('contentType', 'Conversational')}
TOP KEYWORDS: {', '.join(request.analysis.get('topKeywords', []))}
"""
outline_context = ""
if request.outline:
outline_context = f"""
REFINED EPISODE OUTLINE (Follow this structure closely):
Title: {request.outline.get('title', 'N/A')}
Segments: {' | '.join(request.outline.get('segments', []))}
"""
prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes.
{f"PODCAST BIBLE (Hyper-Personalization Context):\n{bible_context}\n" if bible_context else ""}
{f"ANALYSIS CONTEXT:\n{analysis_context}\n" if analysis_context else ""}
{f"REFINED OUTLINE:\n{outline_context}\n" if outline_context else ""}
Podcast Idea: "{request.idea}"
Duration: ~{request.duration_minutes} minutes
Speakers: {request.speakers} (Host + optional Guest)
@@ -83,11 +116,13 @@ Return JSON with:
* Mark "emphasis": true for key statistics or important points
Guidelines:
- Write for spoken delivery: conversational, natural, with contractions
- Use research insights naturally - weave statistics into dialogue, don't just list them
- Vary emotion per scene based on content
- Ensure scenes match target duration: aim for ~2.5 words per second of audio
- Keep it engaging and informative, like a real podcast conversation
- Write for spoken delivery: conversational, natural, with contractions.
- Follow the interaction tone specified in the Bible.
- Ensure the Host persona matches the background and personality traits from the Bible.
- Structure the intro and outro scenes according to the Bible's "Intro Format" and "Outro Format".
- Adhere to any constraints mentioned in the Bible.
- Use insights from the Research Context to ground the conversation in facts.
- IMPORTANT: Follow the REFINED OUTLINE segments as the primary structure for the episode.
"""
try:

View File

@@ -14,7 +14,7 @@ import re
import json
from concurrent.futures import ThreadPoolExecutor
from services.database import get_db
from services.database import get_session_for_user
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
from api.story_writer.utils.auth import require_authenticated_user
from services.wavespeed.infinitetalk import animate_scene_with_voiceover
@@ -105,6 +105,34 @@ def _execute_podcast_video_task(
scene_number_match = re.search(r'\d+', request.scene_id)
scene_number = int(scene_number_match.group()) if scene_number_match else 0
# Fetch project context (Bible & Analysis) from DB if not provided in request
from services.database import get_session_for_user
from services.podcast_service import PodcastService
project_bible = request.bible
project_analysis = None
try:
# Create a dedicated session for this background task
db = get_session_for_user(user_id)
try:
podcast_service = PodcastService(db)
# Fetch project directly from DB to get latest analysis/bible
project = podcast_service.get_project(user_id, request.project_id)
if project:
# Use project bible if request didn't provide one
if not project_bible and project.bible:
project_bible = project.bible
# Get analysis for better context
if project.analysis:
project_analysis = project.analysis
logger.info(f"[Podcast] Loaded analysis for video context: {list(project_analysis.keys())}")
finally:
db.close()
except Exception as e:
logger.warning(f"[Podcast] Failed to fetch project context for video generation: {e}")
# Prepare scene data for animation
scene_data = {
"scene_number": scene_number,
@@ -114,6 +142,8 @@ def _execute_podcast_video_task(
story_context = {
"project_id": request.project_id,
"type": "podcast",
"bible": project_bible,
"analysis": project_analysis,
}
animation_result = animate_scene_with_voiceover(
@@ -207,8 +237,8 @@ def _execute_podcast_video_task(
@router.post("/render/video", response_model=PodcastVideoGenerationResponse)
async def generate_podcast_video(
request_obj: Request,
request: PodcastVideoGenerationRequest,
request: Request,
body: PodcastVideoGenerationRequest,
background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user),
):
@@ -216,22 +246,46 @@ async def generate_podcast_video(
Generate video for a podcast scene using WaveSpeed InfiniteTalk (avatar image + audio).
Returns task_id for polling since InfiniteTalk can take up to 10 minutes.
"""
# Debug logging to identify "Depends object has no attribute get" error source
logger.info(f"[Podcast] generate_podcast_video called. current_user type: {type(current_user)}")
# Check if current_user is a Depends object (FastAPI injection failure)
if hasattr(current_user, "dependency"):
logger.error(f"[Podcast] CRITICAL: current_user is a Depends object! Dependency injection failed.")
# Attempt to manually resolve or fail gracefully
auth_header = None
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
auth_header = request.headers.get("Authorization")
except:
pass
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "").strip()
# Manually verify token if dependency injection failed
from middleware.auth_middleware import clerk_auth
current_user = await clerk_auth.verify_token(token)
if not current_user:
raise HTTPException(status_code=401, detail="Authentication failed (manual recovery)")
else:
raise HTTPException(status_code=401, detail="Authentication failed (injection error)")
user_id = require_authenticated_user(current_user)
logger.info(
f"[Podcast] Starting video generation for project {request.project_id}, scene {request.scene_id}"
f"[Podcast] Starting video generation for project {body.project_id}, scene {body.scene_id}"
)
# Load audio bytes
audio_bytes = load_podcast_audio_bytes(request.audio_url)
audio_bytes = load_podcast_audio_bytes(body.audio_url)
# Validate resolution
if request.resolution not in {"480p", "720p"}:
if body.resolution not in {"480p", "720p"}:
raise HTTPException(status_code=400, detail="Resolution must be '480p' or '720p'.")
# Load image bytes (scene image is required for video generation)
if request.avatar_image_url:
image_bytes = load_podcast_image_bytes(request.avatar_image_url)
if body.avatar_image_url:
image_bytes = load_podcast_image_bytes(body.avatar_image_url)
else:
# Scene-specific image should be generated before video generation
raise HTTPException(
@@ -240,9 +294,9 @@ async def generate_podcast_video(
)
mask_image_bytes = None
if request.mask_image_url:
if body.mask_image_url:
try:
mask_image_bytes = load_podcast_image_bytes(request.mask_image_url)
mask_image_bytes = load_podcast_image_bytes(body.mask_image_url)
except Exception as e:
logger.error(f"[Podcast] Failed to load mask image: {e}")
raise HTTPException(
@@ -251,7 +305,9 @@ async def generate_podcast_video(
)
# Validate subscription limits
db = next(get_db())
db = get_session_for_user(user_id)
if not db:
raise HTTPException(status_code=500, detail="Database session unavailable for user.")
try:
pricing_service = PricingService(db)
validate_scene_animation_operation(pricing_service=pricing_service, user_id=user_id)
@@ -260,16 +316,20 @@ async def generate_podcast_video(
# Extract token for authenticated URL building
auth_token = None
auth_header = request_obj.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
auth_token = auth_header.replace("Bearer ", "").strip()
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
auth_token = auth_header.replace("Bearer ", "").strip()
except Exception as e:
logger.warning(f"[Podcast] Failed to extract auth token from headers: {e}")
# Create async task
task_id = task_manager.create_task("podcast_video_generation")
background_tasks.add_task(
_execute_podcast_video_task,
task_id=task_id,
request=request,
request=body,
user_id=user_id,
image_bytes=image_bytes,
audio_bytes=audio_bytes,

View File

@@ -25,6 +25,7 @@ class PodcastProjectResponse(BaseModel):
raw_research: Optional[Dict[str, Any]] = None
estimate: Optional[Dict[str, Any]] = None
script_data: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
render_jobs: Optional[List[Dict[str, Any]]] = None
knobs: Optional[Dict[str, Any]] = None
research_provider: Optional[str] = None
@@ -34,6 +35,9 @@ class PodcastProjectResponse(BaseModel):
status: str = "draft"
is_favorite: bool = False
final_video_url: Optional[str] = None
avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None
avatar_persona_id: Optional[str] = None
created_at: datetime
updated_at: datetime
@@ -46,6 +50,9 @@ class PodcastAnalyzeRequest(BaseModel):
idea: str = Field(..., description="Podcast topic or idea")
duration: int = Field(default=10, description="Target duration in minutes")
speakers: int = Field(default=1, description="Number of speakers")
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
class PodcastAnalyzeResponse(BaseModel):
@@ -55,7 +62,23 @@ class PodcastAnalyzeResponse(BaseModel):
top_keywords: list[str]
suggested_outlines: list[Dict[str, Any]]
title_suggestions: list[str]
research_queries: Optional[List[Dict[str, str]]] = None
exa_suggested_config: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None
class PodcastEnhanceIdeaRequest(BaseModel):
"""Request model for enhancing a podcast idea with AI."""
idea: str = Field(..., description="The raw podcast idea or keywords")
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
class PodcastEnhanceIdeaResponse(BaseModel):
"""Response model for enhanced podcast idea."""
enhanced_idea: str
rationale: str
class PodcastScriptRequest(BaseModel):
@@ -64,6 +87,9 @@ class PodcastScriptRequest(BaseModel):
duration_minutes: int = Field(default=10, description="Target duration in minutes")
speakers: int = Field(default=1, description="Number of speakers")
research: Optional[Dict[str, Any]] = Field(None, description="Optional research payload to ground the script")
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
outline: Optional[Dict[str, Any]] = Field(None, description="The refined episode outline to follow")
analysis: Optional[Dict[str, Any]] = Field(None, description="The full analysis context (audience, keywords, etc.)")
class PodcastSceneLine(BaseModel):
@@ -106,6 +132,8 @@ class PodcastExaResearchRequest(BaseModel):
topic: str
queries: List[str]
exa_config: Optional[PodcastExaConfig] = None
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
analysis: Optional[Dict[str, Any]] = Field(None, description="Podcast analysis context (audience, content type, etc.)")
class PodcastExaSource(BaseModel):
@@ -117,15 +145,26 @@ class PodcastExaSource(BaseModel):
summary: Optional[str] = None
source_type: Optional[str] = None
index: Optional[int] = None
image: Optional[str] = None
author: Optional[str] = None
class PodcastResearchInsight(BaseModel):
"""Deep insight extracted from research."""
title: str
content: str
source_indices: List[int] = []
class PodcastExaResearchResponse(BaseModel):
sources: List[PodcastExaSource]
search_queries: List[str] = []
summary: str = ""
key_insights: List[PodcastResearchInsight] = []
cost: Optional[Dict[str, Any]] = None
search_type: Optional[str] = None
provider: str = "exa"
content: Optional[str] = None
content: Optional[str] = None # Raw aggregated content (deprecated)
class PodcastScriptResponse(BaseModel):
@@ -191,6 +230,7 @@ class UpdateProjectRequest(BaseModel):
raw_research: Optional[Dict[str, Any]] = None
estimate: Optional[Dict[str, Any]] = None
script_data: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
render_jobs: Optional[List[Dict[str, Any]]] = None
knobs: Optional[Dict[str, Any]] = None
research_provider: Optional[str] = None
@@ -224,6 +264,7 @@ class PodcastImageRequest(BaseModel):
scene_content: Optional[str] = None # Optional: scene lines text for context
idea: Optional[str] = None # Optional: podcast idea for context
base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
width: int = 1024
height: int = 1024
custom_prompt: Optional[str] = None # Custom prompt from user (overrides auto-generated prompt)
@@ -252,6 +293,7 @@ class PodcastVideoGenerationRequest(BaseModel):
scene_title: str = Field(..., description="Scene title")
audio_url: str = Field(..., description="URL to the generated audio file")
avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)")
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
resolution: str = Field("720p", description="Video resolution (480p or 720p)")
prompt: Optional[str] = Field(None, description="Optional animation prompt override")
seed: Optional[int] = Field(-1, description="Random seed; -1 for random")