fix: corregir 4 puntos de falla del pipeline de análisis

- validador: normaliza enums de GPT-4o (acentos, mayúsculas, aliases como
  "ninguno"→"ninguna", "shock"→"declaracion_shock") y coerce strings
  numéricos a enteros antes de validar con Zod
- transcriptor: reintentos automáticos (3 intentos, backoff 1.2s) para
  URLs de CDN inestables; mejor mensaje de error en transcripción vacía
- analizador: captura JSON.parse inválido con mensaje diagnóstico
- vercel.json: aumenta timeout de analizar a 300s y generar a 120s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 09:57:49 -05:00
parent 14372b5b29
commit 21cf1c16a5
4 changed files with 129 additions and 32 deletions

View File

@ -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)}`)
}
}

View File

@ -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()

View File

@ -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),
})

View File

@ -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" },