import { ResearchProvider, ResearchConfig } from "./blogWriterApi"; import { storyWriterApi, StorySetupGenerationResponse, } from "./storyWriterApi"; import { getResearchConfig, ResearchPersona } from "../api/researchConfig"; import { aiApiClient } from "../api/client"; import { CreateProjectPayload, CreateProjectResult, Fact, Knobs, PodcastAnalysis, PodcastEstimate, Query, RenderJobResult, Research, Scene, Script, } from "../components/PodcastMaker/types"; import { checkPreflight, PreflightOperation } from "./billingService"; import { TaskStatus } from "./storyWriterApi"; const DEFAULT_KNOBS: Knobs = { voice_emotion: "neutral", voice_speed: 1, resolution: "720p", scene_length_target: 45, sample_rate: 24000, bitrate: "standard", }; // const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const createId = (prefix: string) => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}_${crypto.randomUUID()}`; } return `${prefix}_${Date.now()}_${Math.floor(Math.random() * 10000)}`; }; type OptionLike = StorySetupGenerationResponse["options"][0] | { plot_elements?: string; premise?: string }; const deriveSegments = (option?: OptionLike): string[] => { const segments: string[] = []; if (option?.plot_elements) { option.plot_elements .split(/[,.;]+/) .map((p) => p.trim()) .filter(Boolean) .forEach((p) => segments.push(p)); } if (!segments.length && "premise" in (option || {}) && (option as any)?.premise) { segments.push("Intro", "Key Takeaways", "Examples", "CTA"); } return segments.slice(0, 5); }; const estimateCosts = ({ minutes, scenes, chars, quality, avatars, queryCount = 3, }: { minutes: number; scenes: number; chars: number; quality: string; avatars: number; queryCount?: number; }): 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); return { ttsCost: +ttsCost.toFixed(2), avatarCost: +avatarCost.toFixed(2), videoCost: +videoCost.toFixed(2), researchCost, total, }; }; const mapPersonaQueries = (persona: ResearchPersona | undefined, seed: string): Query[] => { const baseIdea = seed || "AI marketing for small businesses"; const personaKeywords = persona?.suggested_keywords?.filter(Boolean) || []; const angles = persona?.research_angles ?? []; const generated: Query[] = []; const addQuery = (q: string, why: string, needsRecent = false) => { if (!q.trim()) return; generated.push({ id: createId("q"), query: q.trim(), rationale: why, needsRecentStats: needsRecent, }); }; if (personaKeywords.length) { personaKeywords.slice(0, 4).forEach((k, idx) => addQuery(k, angles[idx % Math.max(1, angles.length)] || "Persona-aligned query", /202[45]|latest|trend/i.test(k)) ); } if (!generated.length) { addQuery(`How is ${baseIdea} evolving in 2024?`, "Trend + outcome focus", true); addQuery(`Best practices for ${baseIdea}`, "Actionable guidance", false); addQuery(`${baseIdea} case studies with ROI`, "Proof and outcomes", true); addQuery(`${baseIdea} risks and objections`, "Address listener concerns", false); } return generated.slice(0, 6); }; const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => { if (!sources || !sources.length) return []; return sources.slice(0, 12).map((source: ExaSource, idx: number) => ({ id: source.url || createId("fact"), quote: source.excerpt || source.title || "Insight", url: source.url || "", date: source.published_at || "Unknown", confidence: typeof (source as any).credibility_score === "number" ? (source as any).credibility_score : Math.max(0.5, 0.85 - idx * 0.02), image: source.image, author: source.author, highlights: source.highlights, })); }; type ExaSource = { title?: string; url?: string; excerpt?: string; published_at?: string; highlights?: string[]; summary?: string; source_type?: string; index?: number; image?: string; author?: string; }; type ExaResearchResult = { sources: ExaSource[]; search_queries?: string[]; cost?: { total?: number }; search_type?: string; provider?: string; content?: string; }; const mapExaResearchResponse = (response: any): Research => { const factCards = mapSourcesToFacts(response.sources); // Use backend summary if available, otherwise use full content (no truncation) or fallback text const summary = response.summary || response.content || "Research completed."; const keyInsights = (response.key_insights || []).map((insight: any) => ({ title: insight.title || "Insight", content: insight.content || "", source_indices: insight.source_indices || [] })); const expertQuotes = (response.expert_quotes || []).map((eq: any) => ({ quote: eq.quote || eq.text || "", source_index: eq.source_index ?? 0 })); const listenerCta = response.listener_cta || []; const mappedAngles = (response.mapped_angles || []).map((angle: any) => ({ title: angle.title || "", why: angle.why || angle.rationale || "", mappedFactIds: angle.mapped_fact_ids || angle.mappedFactIds || [] })); return { summary, keyInsights, factCards, mappedAngles, expertQuotes, listenerCta, searchQueries: response.search_queries, searchType: response.search_type, provider: response.provider || "exa", cost: response.cost?.total, sourceCount: response.sources?.length || 0, }; }; const ensurePreflight = async (operation: PreflightOperation) => { const result = await checkPreflight(operation); if (!result.can_proceed) { const message = result.operations[0]?.message || "Pre-flight validation failed"; throw new Error(message); } return result; }; export const podcastApi = { async createProject(payload: CreateProjectPayload, bible?: any, feedback?: string): Promise { const storyIdea = payload.ideaOrUrl || "AI marketing for small businesses"; await ensurePreflight({ provider: "gemini", operation_type: "podcast_analysis", tokens_requested: 1500, actual_provider_name: "gemini", }); // Podcast-specific analysis (not story setup) const analysisResp = await aiApiClient.post("/api/podcast/analyze", { idea: storyIdea, duration: payload.duration, speakers: payload.speakers, bible: bible, avatar_url: payload.avatarUrl, feedback: feedback, // Pass feedback to backend }); const outlines = (analysisResp.data?.suggested_outlines || []).map((o: any, idx: number) => ({ id: o.id || `outline-${idx + 1}`, title: o.title || `Outline ${idx + 1}`, segments: Array.isArray(o.segments) ? o.segments : deriveSegments({ plot_elements: o.segments }), })); const analysis: PodcastAnalysis = { audience: analysisResp.data?.audience || "Growth-minded pros", contentType: analysisResp.data?.content_type || "Podcast interview", topKeywords: analysisResp.data?.top_keywords || outlines[0]?.segments?.slice(0, 3) || [], suggestedOutlines: outlines, suggestedKnobs: { ...DEFAULT_KNOBS, ...payload.knobs }, titleSuggestions: (analysisResp.data?.title_suggestions || []).filter(Boolean), research_queries: analysisResp.data?.research_queries || [], exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined, }; const researchConfig = await getResearchConfig().catch(() => null); // Use AI-generated queries if available, fallback to legacy mapping let queries: Query[] = []; if (analysis.research_queries && analysis.research_queries.length > 0) { queries = analysis.research_queries.map(rq => ({ id: createId("q"), query: rq.query, rationale: rq.rationale, needsRecentStats: /202[45]|latest|trend/i.test(rq.query) })); } else { queries = mapPersonaQueries(researchConfig?.research_persona, storyIdea); } const projectId = createId("podcast"); const estimate = estimateCosts({ minutes: payload.duration, scenes: Math.ceil((payload.duration * 60) / (payload.knobs.scene_length_target || DEFAULT_KNOBS.scene_length_target)), chars: Math.max(1000, payload.duration * 900), quality: payload.knobs.bitrate || "standard", avatars: payload.speakers, queryCount: queries.length || 3, }); return { projectId, analysis, estimate, queries, bible: analysisResp.data?.bible || undefined, avatar_url: analysisResp.data?.avatar_url || null, avatar_prompt: analysisResp.data?.avatar_prompt || null, }; }, async enhanceIdea(params: { idea: string; bible?: any }): Promise<{ enhanced_ideas: string[]; rationales: string[] }> { const response = await aiApiClient.post("/api/podcast/idea/enhance", params); return response.data; }, async runResearch(params: { projectId: string; topic: string; approvedQueries: Query[]; provider?: ResearchProvider; exaConfig?: ResearchConfig; bible?: any; analysis?: PodcastAnalysis | null; onProgress?: (message: string) => void; }): Promise<{ research: Research; raw: any }> { const keywords = params.approvedQueries.map((q) => q.query).filter(Boolean); if (!keywords.length) { throw new Error("At least one query must be approved for research."); } // Ensure Exa payload respects API constraint: when requesting contents, only one of includeDomains or excludeDomains. let sanitizedExaConfig: ResearchConfig | undefined = params.exaConfig; if (sanitizedExaConfig && sanitizedExaConfig.exa_include_domains?.length) { sanitizedExaConfig = { ...sanitizedExaConfig, exa_exclude_domains: undefined, }; } else if (sanitizedExaConfig && sanitizedExaConfig.exa_exclude_domains?.length) { sanitizedExaConfig = { ...sanitizedExaConfig, exa_include_domains: undefined, }; } await ensurePreflight({ provider: "exa", operation_type: "exa_neural_search", tokens_requested: 0, actual_provider_name: "exa", }); const response = await aiApiClient.post("/api/podcast/research/exa", { topic: params.topic || keywords[0], queries: keywords, exa_config: sanitizedExaConfig, bible: params.bible, analysis: params.analysis, }); const exaResult = response.data as ExaResearchResult; if (params.onProgress) { params.onProgress("Deep research completed with Exa."); } const mapped = mapExaResearchResponse(exaResult); return { research: mapped, raw: exaResult }; }, async generateScript(params: { projectId: string; idea: string; research?: ExaResearchResult | null; knobs: Knobs; speakers: number; durationMinutes: number; bible?: any; outline?: any; analysis?: PodcastAnalysis | null; }): Promise