Use backend-provided podcast estimates and remove UI heuristics
This commit is contained in:
@@ -17,6 +17,8 @@ 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_text_generation import llm_text_gen
|
||||||
from services.llm_providers.main_image_generation import generate_image
|
from services.llm_providers.main_image_generation import generate_image
|
||||||
from services.podcast_bible_service import PodcastBibleService
|
from services.podcast_bible_service import PodcastBibleService
|
||||||
|
from services.subscription import PricingService
|
||||||
|
from models.subscription_models import APIProvider
|
||||||
from utils.asset_tracker import save_asset_to_library
|
from utils.asset_tracker import save_asset_to_library
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import os
|
import os
|
||||||
@@ -33,6 +35,82 @@ def _is_podcast_only_mode() -> bool:
|
|||||||
"""Check if podcast-only demo mode is enabled."""
|
"""Check if podcast-only demo mode is enabled."""
|
||||||
return os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast"
|
return os.getenv("ALWRITY_ENABLED_FEATURES", "").strip().lower() == "podcast"
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tokens(text: str) -> int:
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
return max(1, len(text) // 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_analysis_estimate(
|
||||||
|
db: Session,
|
||||||
|
idea: str,
|
||||||
|
duration: int,
|
||||||
|
speakers: int,
|
||||||
|
has_avatar: bool,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Build a user-facing estimate from pricing catalog and phase-level assumptions.
|
||||||
|
"""
|
||||||
|
# Defaults if catalog lookup fails
|
||||||
|
gemini_in_token = 0.00000015
|
||||||
|
gemini_out_token = 0.0000006
|
||||||
|
exa_per_request = 0.005
|
||||||
|
image_per_request = 0.01
|
||||||
|
video_per_request = 0.01
|
||||||
|
audio_per_request = 0.005
|
||||||
|
|
||||||
|
try:
|
||||||
|
pricing_service = PricingService(db)
|
||||||
|
gemini_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.GEMINI, "gemini-2.5-flash") or {}
|
||||||
|
gemini_in_token = float(gemini_pricing.get("cost_per_input_token") or gemini_in_token)
|
||||||
|
gemini_out_token = float(gemini_pricing.get("cost_per_output_token") or gemini_out_token)
|
||||||
|
exa_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.EXA, "exa-search") or {}
|
||||||
|
exa_per_request = float(exa_pricing.get("cost_per_request") or exa_per_request)
|
||||||
|
img_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.STABILITY, "stable-image-ultra") or {}
|
||||||
|
image_per_request = float(img_pricing.get("cost_per_request") or image_per_request)
|
||||||
|
video_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.VIDEO, "minimax-video-01") or {}
|
||||||
|
video_per_request = float(video_pricing.get("cost_per_request") or video_per_request)
|
||||||
|
audio_pricing = pricing_service.get_pricing_for_provider_model(APIProvider.AUDIO, "gemini-2.5-flash-preview-tts") or {}
|
||||||
|
audio_per_request = float(audio_pricing.get("cost_per_request") or audio_per_request)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[Podcast Analyze] Pricing catalog lookup failed, using defaults: {exc}")
|
||||||
|
|
||||||
|
# Phase assumptions
|
||||||
|
query_count = 5
|
||||||
|
analyze_in = _estimate_tokens(idea) + 240
|
||||||
|
analyze_out = 750
|
||||||
|
analyze_cost = (analyze_in * gemini_in_token) + (analyze_out * gemini_out_token)
|
||||||
|
|
||||||
|
gather_cost = query_count * exa_per_request
|
||||||
|
|
||||||
|
script_chars = max(1000, duration * 900)
|
||||||
|
write_in = _estimate_tokens(idea) + _estimate_tokens(str(script_chars)) + 320
|
||||||
|
write_out = max(900, int(duration * 220))
|
||||||
|
write_cost = (write_in * gemini_in_token) + (write_out * gemini_out_token)
|
||||||
|
|
||||||
|
tts_cost = max(1, speakers) * audio_per_request
|
||||||
|
avatar_cost = 0.0 if has_avatar else image_per_request
|
||||||
|
video_cost = max(1, duration) * video_per_request
|
||||||
|
produce_cost = tts_cost + avatar_cost + video_cost
|
||||||
|
|
||||||
|
breakdown = [
|
||||||
|
{"phase": "Analyze", "cost": round(analyze_cost, 6)},
|
||||||
|
{"phase": "Gather", "cost": round(gather_cost, 6)},
|
||||||
|
{"phase": "Write", "cost": round(write_cost, 6)},
|
||||||
|
{"phase": "Produce", "cost": round(produce_cost, 6)},
|
||||||
|
]
|
||||||
|
total = round(sum(item["cost"] for item in breakdown), 6)
|
||||||
|
return {
|
||||||
|
"ttsCost": round(tts_cost, 6),
|
||||||
|
"avatarCost": round(avatar_cost, 6),
|
||||||
|
"videoCost": round(video_cost, 6),
|
||||||
|
"researchCost": round(gather_cost, 6),
|
||||||
|
"total": total,
|
||||||
|
"breakdown": breakdown,
|
||||||
|
"currency": "USD",
|
||||||
|
}
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -388,6 +466,13 @@ Requirements:
|
|||||||
bible=bible_obj.model_dump() if bible_obj else None,
|
bible=bible_obj.model_dump() if bible_obj else None,
|
||||||
avatar_url=final_avatar_url,
|
avatar_url=final_avatar_url,
|
||||||
avatar_prompt=final_avatar_prompt,
|
avatar_prompt=final_avatar_prompt,
|
||||||
|
estimate=_build_analysis_estimate(
|
||||||
|
db=db,
|
||||||
|
idea=request.idea,
|
||||||
|
duration=request.duration,
|
||||||
|
speakers=request.speakers,
|
||||||
|
has_avatar=bool(final_avatar_url),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -492,4 +577,3 @@ Requirements:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
logger.error(f"[Regenerate Queries] Failed for user {user_id}: {exc}")
|
||||||
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
raise HTTPException(status_code=500, detail=f"Regenerate queries failed: {exc}")
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class PodcastAnalyzeResponse(BaseModel):
|
|||||||
bible: Optional[Dict[str, Any]] = None
|
bible: Optional[Dict[str, Any]] = None
|
||||||
avatar_url: Optional[str] = None
|
avatar_url: Optional[str] = None
|
||||||
avatar_prompt: Optional[str] = None
|
avatar_prompt: Optional[str] = None
|
||||||
|
estimate: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
class PodcastEnhanceIdeaRequest(BaseModel):
|
class PodcastEnhanceIdeaRequest(BaseModel):
|
||||||
|
|||||||
@@ -253,26 +253,6 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
|
setShowAIDetailsButton(topicInput.trim().length > 0 && !isUrl);
|
||||||
}, [topicInput, isUrl]);
|
}, [topicInput, isUrl]);
|
||||||
|
|
||||||
// Calculate estimated cost
|
|
||||||
const estimatedCost = useMemo(() => {
|
|
||||||
const chars = Math.max(1000, duration * 900); // ~900 chars per minute
|
|
||||||
const secs = duration * 60;
|
|
||||||
|
|
||||||
const ttsCost = (chars / 1000) * 0.05;
|
|
||||||
const avatarCost = speakers * 0.15;
|
|
||||||
const videoRate = knobs.bitrate === 'hd' ? 0.06 : 0.03;
|
|
||||||
const videoCost = secs * videoRate;
|
|
||||||
const researchCost = 0.3; // Fixed research cost
|
|
||||||
|
|
||||||
return {
|
|
||||||
ttsCost: +ttsCost.toFixed(2),
|
|
||||||
avatarCost: +avatarCost.toFixed(2),
|
|
||||||
videoCost: +videoCost.toFixed(2),
|
|
||||||
researchCost: +researchCost.toFixed(2),
|
|
||||||
total: +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2),
|
|
||||||
};
|
|
||||||
}, [duration, speakers, knobs.bitrate, knobs.scene_length_target]);
|
|
||||||
|
|
||||||
// Check if avatar is present (from any source: upload, brand avatar, or generated)
|
// Check if avatar is present (from any source: upload, brand avatar, or generated)
|
||||||
const hasAvatar = Boolean(
|
const hasAvatar = Boolean(
|
||||||
avatarFile || // User uploaded an image
|
avatarFile || // User uploaded an image
|
||||||
@@ -560,7 +540,7 @@ export const CreateModal: React.FC<CreateModalProps> = ({ onCreate, open, defaul
|
|||||||
placeholderIndex={placeholderIndex}
|
placeholderIndex={placeholderIndex}
|
||||||
loading={enhancingTopic}
|
loading={enhancingTopic}
|
||||||
loadingMessage={enhanceTopicMessage}
|
loadingMessage={enhanceTopicMessage}
|
||||||
estimatedCost={estimatedCost}
|
estimatedCost={null}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
knobs={knobs}
|
knobs={knobs}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface TopicUrlInputProps {
|
|||||||
videoCost: number;
|
videoCost: number;
|
||||||
researchCost: number;
|
researchCost: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
} | null;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
speakers?: number;
|
speakers?: number;
|
||||||
knobs?: Knobs;
|
knobs?: Knobs;
|
||||||
@@ -115,7 +115,7 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{estimatedCost && (
|
{estimatedCost ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box>
|
<Box>
|
||||||
@@ -171,6 +171,20 @@ export const TopicUrlInput: React.FC<TopicUrlInputProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
icon={<AttachMoneyIcon sx={{ fontSize: "0.875rem !important" }} />}
|
||||||
|
label="Est. Unavailable"
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
background: "rgba(148, 163, 184, 0.12)",
|
||||||
|
color: "#64748b",
|
||||||
|
fontWeight: 600,
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.2)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
height: 26,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ export type PodcastEstimate = {
|
|||||||
videoCost: number;
|
videoCost: number;
|
||||||
researchCost: number;
|
researchCost: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
breakdown?: Array<{ phase: "Analyze" | "Gather" | "Write" | "Produce"; cost: number }>;
|
||||||
|
currency?: "USD";
|
||||||
|
lastUpdated?: string;
|
||||||
voiceName?: string;
|
voiceName?: string;
|
||||||
isCustomVoice?: boolean;
|
isCustomVoice?: boolean;
|
||||||
};
|
};
|
||||||
@@ -196,7 +199,7 @@ export type CreateProjectPayload = {
|
|||||||
export type CreateProjectResult = {
|
export type CreateProjectResult = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
analysis: PodcastAnalysis;
|
analysis: PodcastAnalysis;
|
||||||
estimate: PodcastEstimate;
|
estimate: PodcastEstimate | null;
|
||||||
queries: Query[];
|
queries: Query[];
|
||||||
bible?: PodcastBible;
|
bible?: PodcastBible;
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
|
|||||||
@@ -59,43 +59,6 @@ const deriveSegments = (option?: OptionLike): string[] => {
|
|||||||
return segments.slice(0, 5);
|
return segments.slice(0, 5);
|
||||||
};
|
};
|
||||||
|
|
||||||
const estimateCosts = ({
|
|
||||||
minutes,
|
|
||||||
scenes,
|
|
||||||
chars,
|
|
||||||
quality,
|
|
||||||
avatars,
|
|
||||||
queryCount = 3,
|
|
||||||
voiceId,
|
|
||||||
}: {
|
|
||||||
minutes: number;
|
|
||||||
scenes: number;
|
|
||||||
chars: number;
|
|
||||||
quality: string;
|
|
||||||
avatars: number;
|
|
||||||
queryCount?: number;
|
|
||||||
voiceId?: string;
|
|
||||||
}): PodcastEstimate => {
|
|
||||||
const secs = Math.max(60, minutes * 60);
|
|
||||||
const ttsCost = (chars / 1000) * 0.05;
|
|
||||||
const avatarCost = avatars * 0.15;
|
|
||||||
const videoRate = quality === "hd" ? 0.06 : 0.03;
|
|
||||||
const videoCost = secs * videoRate;
|
|
||||||
const researchCost = +(Math.max(1, queryCount) * 0.1).toFixed(2);
|
|
||||||
const total = +(ttsCost + avatarCost + videoCost + researchCost).toFixed(2);
|
|
||||||
const isCustomVoice = Boolean(voiceId && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId));
|
|
||||||
const voiceName = isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " "));
|
|
||||||
return {
|
|
||||||
ttsCost: +ttsCost.toFixed(2),
|
|
||||||
avatarCost: +avatarCost.toFixed(2),
|
|
||||||
videoCost: +videoCost.toFixed(2),
|
|
||||||
researchCost,
|
|
||||||
total,
|
|
||||||
voiceName,
|
|
||||||
isCustomVoice,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
|
const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => {
|
||||||
const baseIdea = seed || "AI marketing for small businesses";
|
const baseIdea = seed || "AI marketing for small businesses";
|
||||||
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
|
const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || [];
|
||||||
@@ -302,15 +265,21 @@ export const podcastApi = {
|
|||||||
// so users can manually choose which queries to run
|
// so users can manually choose which queries to run
|
||||||
|
|
||||||
const projectId = createId("podcast");
|
const projectId = createId("podcast");
|
||||||
const estimate = estimateCosts({
|
const estimateData = analysisResp.data?.estimate;
|
||||||
minutes: payload.duration,
|
const estimate: PodcastEstimate | null = estimateData
|
||||||
scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)),
|
? {
|
||||||
chars: Math.max(1000, payload.duration * 900),
|
ttsCost: Number(estimateData.ttsCost ?? 0),
|
||||||
quality: payload.knobs.bitrate || "standard",
|
avatarCost: Number(estimateData.avatarCost ?? 0),
|
||||||
avatars: payload.speakers,
|
videoCost: Number(estimateData.videoCost ?? 0),
|
||||||
queryCount: queries.length || 3,
|
researchCost: Number(estimateData.researchCost ?? 0),
|
||||||
voiceId: payload.knobs.voice_id,
|
total: Number(estimateData.total ?? 0),
|
||||||
});
|
breakdown: Array.isArray(estimateData.breakdown) ? estimateData.breakdown : [],
|
||||||
|
currency: estimateData.currency || "USD",
|
||||||
|
lastUpdated: estimateData.last_updated || estimateData.lastUpdated,
|
||||||
|
voiceName: estimateData.voiceName,
|
||||||
|
isCustomVoice: estimateData.isCustomVoice,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
Reference in New Issue
Block a user