diff --git a/backend/lib/analizador.js b/backend/lib/analizador.js index a430468..5947503 100644 --- a/backend/lib/analizador.js +++ b/backend/lib/analizador.js @@ -122,5 +122,9 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan: .replace(/\n?```$/, '') .trim() - return JSON.parse(jsonLimpio) + try { + return JSON.parse(jsonLimpio) + } catch { + throw new Error(`GPT-4o devolvió JSON inválido. Primeros 200 chars: ${jsonLimpio.slice(0, 200)}`) + } } diff --git a/backend/lib/transcriptor.js b/backend/lib/transcriptor.js index cbbd1fc..6066ec4 100644 --- a/backend/lib/transcriptor.js +++ b/backend/lib/transcriptor.js @@ -1,26 +1,44 @@ // ============================================================ // TRANSCRIPTOR — OpenAI Whisper // Descarga el audio desde la URL y lo transcribe +// Incluye reintentos automáticos para URLs de CDN inestables // ============================================================ import OpenAI, { toFile } from 'openai' const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) +/** + * Descarga una URL con reintentos (para CDNs que expiran o fallan transitoriamente) + * @param {string} url + * @param {number} intentos Máximo de intentos (default: 3) + * @returns {Response} + */ +async function fetchConReintentos(url, intentos = 3) { + let ultimoError + for (let i = 1; i <= intentos; i++) { + try { + const res = await fetch(url) + if (res.ok) return res + ultimoError = new Error(`HTTP ${res.status} al descargar audio (intento ${i}/${intentos})`) + } catch (err) { + ultimoError = err + } + if (i < intentos) await new Promise(r => setTimeout(r, 1200 * i)) // backoff: 1.2s, 2.4s + } + throw ultimoError +} + /** * @param {string} audioUrl URL directa del MP3 (de Social Download API) * @param {string} idioma Código de idioma: 'es', 'en', 'pt', etc. * @returns {string} Transcripción completa del audio */ export async function transcribir(audioUrl, idioma = 'es') { - // Descargar el audio desde la URL del CDN - const audioResponse = await fetch(audioUrl) - if (!audioResponse.ok) { - throw new Error(`Error al descargar audio: ${audioResponse.status}`) - } + const audioResponse = await fetchConReintentos(audioUrl) // En Vercel Serverless (Node < 20), Web API `File` no está disponible por defecto, // y `arrayBuffer` consume mucha RAM. `toFile` soluciona ambos. - const audioFile = await toFile(audioResponse, 'audio.mp3', { type: 'audio/mpeg' }) + const audioFile = toFile(audioResponse, 'audio.mp3', { type: 'audio/mpeg' }) const transcripcion = await openai.audio.transcriptions.create({ file: audioFile, @@ -30,7 +48,10 @@ export async function transcribir(audioUrl, idioma = 'es') { }) if (!transcripcion || transcripcion.trim().length < 10) { - throw new Error('Whisper no pudo transcribir el audio (resultado vacío o muy corto)') + throw new Error( + 'Whisper no detectó voz suficiente en el audio. ' + + 'Verifica que el video tenga narración clara (no solo música o texto en pantalla).' + ) } return transcripcion.trim() diff --git a/backend/lib/validador.js b/backend/lib/validador.js index adc5648..5aef69e 100644 --- a/backend/lib/validador.js +++ b/backend/lib/validador.js @@ -1,27 +1,98 @@ // ============================================================ // VALIDADOR — Zod Schema // Valida el JSON de GPT-4o antes de guardar en Supabase -// Si GPT-4o alucina un valor fuera del enum, lo atrapa aquí +// Incluye normalización de enums para tolerar variaciones +// comunes de GPT-4o (acentos, mayúsculas, aliases) // ============================================================ import { z } from 'zod' -const EstructuraEnum = z.enum(['AIDA','PAS','hero_journey','storybrand','antes_despues','otra']) -const GanchoTipoEnum = z.enum(['pregunta','declaracion_shock','dato_estadistica','historia','controversia','promesa_directa']) -const DesarrolloEnum = z.enum(['problema_solucion','lista','demostracion','testimonio','tutorial','storytelling_puro']) -const CtaTipoEnum = z.enum(['seguir','comentar','compartir','comprar','visitar_link','guardar','ninguno']) -const PacingEnum = z.enum(['lento','medio','rapido','variable']) -const TriggerEnum = z.enum(['miedo','esperanza','curiosidad','ira','orgullo','tristeza','sorpresa','humor']) -const AtencionVisualEnum = z.enum(['zoom_agresivo','corte_rapido','texto_pantalla','cara_camara','broll_dinamico','ninguno']) -const DolorPlacerEnum = z.enum(['apela_dolor','apela_placer','ambos']) -const CargaEnum = z.enum(['baja','media','alta']) -const VelocidadEnum = z.enum(['lenta','normal','rapida','muy_rapida']) -const TonoEnum = z.enum(['educativo','entretenimiento','inspiracional','controversial','informativo','humoristico']) -const PersonaEnum = z.enum(['primera_persona','segunda_persona','tercera_persona','mixta']) -const EspecificidadEnum = z.enum(['generico','especifico','ultra_especifico']) -const TecnicaRetencionEnum= z.enum(['open_loop','cliffhanger','curiosity_gap','countdown','pregunta_abierta','ninguna']) -const RatioEmocionEnum = z.enum(['emocional','logico','equilibrado']) -const NivelConcienciaEnum = z.enum(['inconsciente','problema_consciente','solucion_consciente','producto_consciente','mas_consciente']) -const ReplicabilidadEnum = z.enum(['alta','media','baja']) +// ── Helpers de normalización ───────────────────────────────── + +function stripAccents(str) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') +} + +/** + * Intenta hacer coincidir `valor` con uno de los valores válidos del enum, + * tolerando diferencias de acentos, mayúsculas y espacios vs guiones bajos. + * Si no encuentra coincidencia, devuelve el valor original para que Zod reporte el error. + */ +function normalizarEnum(valor, validos, aliases = {}) { + if (typeof valor !== 'string') return valor + + // 1. Alias explícitos (e.g. "ninguno" → "ninguna") + if (aliases[valor]) return aliases[valor] + + // 2. Strip de acentos + const sinAcentos = stripAccents(valor).trim() + if (aliases[sinAcentos]) return aliases[sinAcentos] + + // 3. Coincidencia exacta tras strip + if (validos.includes(sinAcentos)) return sinAcentos + + // 4. Coincidencia case-insensitive + const lower = sinAcentos.toLowerCase() + const matchCI = validos.find(v => v.toLowerCase() === lower) + if (matchCI) return matchCI + + // 5. Espacios → guiones bajos + case-insensitive + const underscored = lower.replace(/\s+/g, '_') + const matchU = validos.find(v => v.toLowerCase() === underscored) + if (matchU) return matchU + + return sinAcentos // sin match → Zod reportará el error +} + +/** Enum flexible: tolera acentos, mayúsculas, espacios y aliases explícitos */ +function flexEnum(values, aliases = {}) { + return z.preprocess( + v => normalizarEnum(String(v ?? ''), values, aliases), + z.enum(values) + ) +} + +/** Entero flexible: acepta strings numéricos que GPT-4o a veces devuelve */ +function flexInt(min, max) { + return z.preprocess( + v => (typeof v === 'string' && v.trim() !== '' && !isNaN(Number(v))) + ? Math.round(Number(v)) + : v, + z.number().int().min(min).max(max) + ) +} + +// ── Definición de enums ────────────────────────────────────── + +const EstructuraEnum = flexEnum(['AIDA','PAS','hero_journey','storybrand','antes_despues','otra']) +const GanchoTipoEnum = flexEnum( + ['pregunta','declaracion_shock','dato_estadistica','historia','controversia','promesa_directa'], + { 'shock': 'declaracion_shock', 'declaracion': 'declaracion_shock', + 'dato': 'dato_estadistica', 'estadistica': 'dato_estadistica', + 'promesa': 'promesa_directa' } +) +const DesarrolloEnum = flexEnum(['problema_solucion','lista','demostracion','testimonio','tutorial','storytelling_puro']) +const CtaTipoEnum = flexEnum(['seguir','comentar','compartir','comprar','visitar_link','guardar','ninguno']) +const PacingEnum = flexEnum(['lento','medio','rapido','variable']) +const TriggerEnum = flexEnum(['miedo','esperanza','curiosidad','ira','orgullo','tristeza','sorpresa','humor']) +const AtencionVisualEnum = flexEnum(['zoom_agresivo','corte_rapido','texto_pantalla','cara_camara','broll_dinamico','ninguno']) +const DolorPlacerEnum = flexEnum(['apela_dolor','apela_placer','ambos']) +const CargaEnum = flexEnum(['baja','media','alta']) +const VelocidadEnum = flexEnum(['lenta','normal','rapida','muy_rapida'], { 'muy rapida': 'muy_rapida' }) +const TonoEnum = flexEnum( + ['educativo','entretenimiento','inspiracional','controversial','informativo','humoristico'], + { 'humor': 'humoristico', 'comico': 'humoristico', 'humoristico': 'humoristico' } +) +const PersonaEnum = flexEnum(['primera_persona','segunda_persona','tercera_persona','mixta']) +const EspecificidadEnum = flexEnum(['generico','especifico','ultra_especifico']) +const TecnicaRetencionEnum = flexEnum( + ['open_loop','cliffhanger','curiosity_gap','countdown','pregunta_abierta','ninguna'], + { 'ninguno': 'ninguna' } // GPT-4o a veces usa el masculino +) +const RatioEmocionEnum = flexEnum(['emocional','logico','equilibrado']) +const NivelConcienciaEnum = flexEnum(['inconsciente','problema_consciente','solucion_consciente','producto_consciente','mas_consciente']) +const ReplicabilidadEnum = flexEnum(['alta','media','baja']) + +// ── Schema principal ───────────────────────────────────────── export const AnalisisSchema = z.object({ // Storytelling @@ -30,7 +101,7 @@ export const AnalisisSchema = z.object({ gancho_texto: z.string().min(1).max(200), apertura_exacta: z.string().min(1).max(300), cierre_exacto: z.string().min(1).max(200), - gancho_duracion_seg: z.number().int().min(0).max(30), + gancho_duracion_seg: flexInt(0, 30), desarrollo_tipo: DesarrolloEnum, cta_tipo: CtaTipoEnum, cta_texto: z.string().max(300).nullable(), @@ -38,9 +109,9 @@ export const AnalisisSchema = z.object({ conflicto_central: z.string().min(1).max(500), resolucion: z.string().min(1).max(500), pacing_ritmo: PacingEnum, - numero_actos: z.number().int().min(1).max(4), + numero_actos: flexInt(1, 4), tecnica_retencion: TecnicaRetencionEnum, - momento_pico_seg: z.number().int().min(0).max(600), + momento_pico_seg: flexInt(0, 600), // Cialdini cialdini_reciprocidad: z.boolean(), @@ -52,7 +123,7 @@ export const AnalisisSchema = z.object({ cialdini_unidad: z.boolean(), sesgo_cognitivo: z.string().max(100).nullable(), trigger_emocional: TriggerEnum, - intensidad_emocional: z.number().int().min(1).max(10), + intensidad_emocional: flexInt(1, 10), // Neuropublicidad atencion_visual: AtencionVisualEnum, @@ -88,7 +159,7 @@ export const AnalisisSchema = z.object({ hashtags_sugeridos: z.array(z.string()).min(1).max(10), // Métricas - score_virabilidad: z.number().int().min(1).max(100), + score_virabilidad: flexInt(1, 100), resumen_patron: z.string().min(10).max(1500), }) diff --git a/vercel.json b/vercel.json index 7614ef8..c3dbe51 100644 --- a/vercel.json +++ b/vercel.json @@ -2,7 +2,8 @@ "buildCommand": "cd frontend && npm install && npm run build", "outputDirectory": "frontend/dist", "functions": { - "api/**/*.js": { "maxDuration": 60 } + "api/analizar.js": { "maxDuration": 300 }, + "api/generar.js": { "maxDuration": 120 } }, "rewrites": [ { "source": "/api/(.*)", "destination": "/api/$1" },