Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user