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"; import { isPodcastOnlyDemoMode } from "../utils/demoMode"; const DEFAULT_KNOBS: Knobs = { voice_emotion: "neutral", voice_speed: 1, voice_id: "Wise_Woman", custom_voice_id: undefined, 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 toPodcastEstimate = (raw: any, voiceId?: string): PodcastEstimate | null => { if (!raw || typeof raw !== "object") return null; const numeric = ["ttsCost", "avatarCost", "videoCost", "researchCost", "total"] as const; if (numeric.some((key) => typeof raw[key] !== "number" || Number.isNaN(raw[key]))) { return null; } 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) ); return { ttsCost: raw.ttsCost, avatarCost: raw.avatarCost, videoCost: raw.videoCost, researchCost: raw.researchCost, total: raw.total, voiceName: isCustomVoice ? "My Voice Clone" : (!voiceId ? "Wise Woman" : voiceId.replace(/_/g, " ")), isCustomVoice, }; }; 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); }; type ExaSource = { title?: string; url?: string; excerpt?: string; published_at?: string; publishedDate?: string; // Exa format highlights?: string[]; summary?: string; source_type?: string; index?: number; image?: string; author?: string; text?: string; // Exa full text content credibility_score?: number; }; const mapSourcesToFacts = (sources: ExaSource[]): Fact[] => { if (!sources || !sources.length) return []; // Deduplicate by URL const seenUrls = new Set(); const uniqueSources = sources.filter(s => { if (!s.url || seenUrls.has(s.url)) return false; seenUrls.add(s.url); return true; }); return uniqueSources.slice(0, 12).map((source: ExaSource, idx: number) => ({ id: source.url || `fact-${idx}`, quote: source.excerpt || source.highlights?.[0] || source.summary || source.title || "Insight", url: source.url || "", // Use published_at (backend format) or publishedDate (Exa format) date: source.published_at || source.publishedDate || "Unknown", confidence: source.credibility_score || Math.max(0.5, 0.85 - idx * 0.02), image: source.image, author: source.author, highlights: source.highlights, // Include full text if available fullText: source.text, })); }; type ExaResearchResult = { sources: ExaSource[]; search_queries?: string[]; cost_est?: { total?: number; breakdown?: { phase: "Analyze" | "Gather" | "Write" | "Produce"; cost: number }[]; currency?: "USD"; last_updated?: string; }; cost?: { total?: number }; estimate?: PodcastEstimate | null; search_type?: string; provider?: string; content?: string; }; const mapExaResearchResponse = (response: any): Research => { const factCards = mapSourcesToFacts(response.sources); 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_suggestions || 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", costEst: response.cost_est ? { total: Number(response.cost_est.total || 0), breakdown: Array.isArray(response.cost_est.breakdown) ? response.cost_est.breakdown : [], currency: response.cost_est.currency || "USD", last_updated: response.cost_est.last_updated || new Date().toISOString(), } : undefined, sourceCount: response.sources?.length || 0, }; }; const ensurePreflight = async (operation: PreflightOperation) => { console.log('[podcastApi] Running preflight for:', operation); const result = await checkPreflight(operation); console.log('[podcastApi] Preflight result:', result); 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, podcast_mode: payload.podcastMode, // Pass mode to skip avatar for audio_only }); 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), episode_hook: analysisResp.data?.episode_hook || "", key_takeaways: analysisResp.data?.key_takeaways || [], guest_talking_points: analysisResp.data?.guest_talking_points || [], listener_cta: analysisResp.data?.listener_cta || "", research_queries: analysisResp.data?.research_queries || [], exaSuggestedConfig: analysisResp.data?.exa_suggested_config || undefined, }; const researchConfig = isPodcastOnlyDemoMode() ? null : await getResearchConfig(); // 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); } // Note: selectedQueries should be set to empty Set by the caller (workflow) // so users can manually choose which queries to run const projectId = createId("podcast"); const estimate = toPodcastEstimate(analysisResp.data?.estimate, payload.knobs.voice_id); 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; estimate?: PodcastEstimate | null }> { 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", }); let response; try { response = await aiApiClient.post("/api/podcast/research/exa", { topic: params.topic || keywords[0], queries: keywords, exa_config: sanitizedExaConfig, bible: params.bible, analysis: params.analysis, }, { timeout: 300000 }); // 5 minute timeout for research console.log('[podcastApi] Exa research response received:', response.status, response.data); } catch (error: any) { console.error('[podcastApi] Exa research error:', error?.response?.status, error?.response?.data, error?.message); throw error; } 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, estimate: toPodcastEstimate(exaResult.estimate, params.analysis?.suggestedKnobs?.voice_id), }; }, async generateScript(params: { projectId: string; idea: string; research?: ExaResearchResult | null; knobs: Knobs; speakers: number; durationMinutes: number; bible?: any; outline?: any; analysis?: PodcastAnalysis | null; onProgress?: (message: string) => void; }): Promise