Initial commit — Sistema Generador de Guiones V4.0

Pipeline completo: URL → Whisper → GPT-4o → pgvector → Supabase
Frontend Vue 3 + Tailwind, Backend Express + Vercel serverless functions
This commit is contained in:
2026-03-28 16:02:59 -05:00
commit 7695dd0be6
47 changed files with 7552 additions and 0 deletions

106
backend/lib/analizador.js Normal file
View File

@ -0,0 +1,106 @@
// ============================================================
// ANALIZADOR — GPT-4o
// Prompt maestro multidisciplinario: Storytelling + Cialdini
// + Neuropublicidad → JSON de 55 campos analizables
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
const PROMPT_SISTEMA = `Eres un experto en ingeniería de guiones para video corto (TikTok, Reels, YouTube Shorts) con especialización en:
- Storytelling y estructura narrativa
- Psicología de la persuasión (Cialdini, sesgos cognitivos)
- Neuropublicidad y neuromarketing
- Marketing de contenidos para múltiples nichos
Tu tarea es analizar la transcripción de un video y devolver un JSON con el análisis completo.
SOLO devuelve el JSON, sin texto adicional, sin markdown, sin explicaciones.`
/**
* @param {string} transcript Texto transcrito por Whisper
* @param {string} niche Nicho del video (ej: "fitness", "finanzas")
* @param {string} plataforma tiktok | reels | shorts
* @param {number} duracion Duración en segundos
* @returns {object} JSON con todos los campos de análisis
*/
export async function analizarTranscript(transcript, niche, plataforma, duracion) {
const promptUsuario = `Analiza este video de ${plataforma} de ${duracion} segundos del nicho "${niche}".
TRANSCRIPCIÓN:
"""
${transcript}
"""
Devuelve EXACTAMENTE este JSON con los valores que correspondan:
{
"estructura_narrativa": "<AIDA|PAS|hero_journey|storybrand|antes_despues|otra>",
"gancho_tipo": "<pregunta|declaracion_shock|dato_estadistica|historia|controversia|promesa_directa>",
"gancho_texto": "<primeras 5-8 palabras del video>",
"gancho_duracion_seg": <número entero estimado>,
"desarrollo_tipo": "<problema_solucion|lista|demostracion|testimonio|tutorial|storytelling_puro>",
"cta_tipo": "<seguir|comentar|compartir|comprar|visitar_link|guardar|ninguno>",
"cta_texto": "<texto exacto del call to action, o null si no hay>",
"arco_emocional": "<emoción inicial> → <emoción media> → <emoción final>",
"conflicto_central": "<el problema o tensión principal que articula el video>",
"resolucion": "<cómo se resuelve o qué promete resolver>",
"pacing_ritmo": "<lento|medio|rapido|variable>",
"numero_actos": <1, 2 o 3>,
"cialdini_reciprocidad": <true|false>,
"cialdini_escasez": <true|false>,
"cialdini_autoridad": <true|false>,
"cialdini_consistencia": <true|false>,
"cialdini_prueba_social": <true|false>,
"cialdini_simpatia": <true|false>,
"cialdini_unidad": <true|false>,
"sesgo_cognitivo": "<nombre del sesgo cognitivo principal, o null>",
"trigger_emocional": "<miedo|esperanza|curiosidad|ira|orgullo|tristeza|sorpresa|humor>",
"intensidad_emocional": <número entero del 1 al 10>,
"atencion_visual": "<zoom_agresivo|corte_rapido|texto_pantalla|cara_camara|broll_dinamico|ninguno>",
"lenguaje_sensorial": <true|false>,
"contraste_narrativo": <true|false>,
"efecto_novedad": <true|false>,
"dolor_placer": "<apela_dolor|apela_placer|ambos>",
"personalizacion": <true|false>,
"carga_cognitiva": "<baja|media|alta>",
"velocidad_locucion": "<lenta|normal|rapida|muy_rapida>",
"uso_musica": <true|false>,
"micro_compromisos": <true|false>,
"tema_principal": "<tema en 1-3 palabras>",
"angulo_unico": "<qué diferencia a este video de otros del mismo tema, en 1 oración>",
"palabras_clave": ["<keyword1>", "<keyword2>", "<keyword3>", "<keyword4>", "<keyword5>"],
"tono": "<educativo|entretenimiento|inspiracional|controversial|informativo|humoristico>",
"persona_narradora": "<primera_persona|segunda_persona|tercera_persona|mixta>",
"promesa_explicita": "<la promesa que hace el video al espectador, en 1 oración>",
"nivel_especificidad": "<generico|especifico|ultra_especifico>",
"score_virabilidad": <número entero del 1 al 100>,
"resumen_patron": "<párrafo de 2-3 oraciones describiendo el patrón ganador de este video>"
}`
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
temperature: 0.2, // baja temperatura para análisis consistente
messages: [
{ role: 'system', content: PROMPT_SISTEMA },
{ role: 'user', content: promptUsuario },
],
})
const contenido = completion.choices[0]?.message?.content?.trim()
if (!contenido) {
throw new Error('GPT-4o devolvió una respuesta vacía')
}
// Limpiar posible markdown que GPT-4o a veces añade
const jsonLimpio = contenido
.replace(/^```json\n?/, '')
.replace(/^```\n?/, '')
.replace(/\n?```$/, '')
.trim()
return JSON.parse(jsonLimpio)
}

40
backend/lib/embeddings.js Normal file
View File

@ -0,0 +1,40 @@
// ============================================================
// EMBEDDINGS — text-embedding-3-small
// Genera el vector semántico para búsqueda con pgvector
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
/**
* Genera el vector de embedding para un guion.
* Vectorizamos un texto compuesto para capturar tanto el
* contenido como los patrones estructurales del video.
*
* @param {string} transcript Transcripción completa
* @param {object} analisis JSON validado de GPT-4o
* @returns {number[]} Vector de 1536 dimensiones
*/
export async function generarEmbedding(transcript, analisis) {
// Construir el texto a vectorizar combinando campos clave
// Esto hace que la búsqueda semántica encuentre videos similares
// en CONTENIDO y en ESTRUCTURA (no solo en palabras)
const textoParaVectorizar = [
`Tema: ${analisis.tema_principal}`,
`Ángulo: ${analisis.angulo_unico}`,
`Estructura: ${analisis.estructura_narrativa}`,
`Gancho: ${analisis.gancho_texto}`,
`Conflicto: ${analisis.conflicto_central}`,
`Trigger emocional: ${analisis.trigger_emocional}`,
`Promesa: ${analisis.promesa_explicita}`,
`Patrón: ${analisis.resumen_patron}`,
`Transcript: ${transcript.slice(0, 1000)}`, // primeros 1000 chars
].join('\n')
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: textoParaVectorizar,
})
return response.data[0].embedding
}

54
backend/lib/extractor.js Normal file
View File

@ -0,0 +1,54 @@
// ============================================================
// EXTRACTOR — Social Download All In One (RapidAPI)
// Devuelve la URL del audio MP3 y metadata del video
// ============================================================
const RAPIDAPI_HOST = 'social-download-all-in-one.p.rapidapi.com'
const RAPIDAPI_URL = `https://${RAPIDAPI_HOST}/v1/social/autolink`
/**
* @param {string} url URL del video (TikTok, Reels, YouTube Shorts)
* @returns {{ audioUrl: string, duracion: number, titulo: string, thumbnail: string, plataforma: string }}
*/
export async function extraerAudio(url) {
const response = await fetch(RAPIDAPI_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-rapidapi-host': RAPIDAPI_HOST,
'x-rapidapi-key': process.env.RAPIDAPI_KEY,
},
body: JSON.stringify({ url }),
})
if (!response.ok) {
throw new Error(`Social Download API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
if (data.error) {
throw new Error(`Social Download API devolvió error para la URL: ${url}`)
}
// Buscar el media de tipo audio
const audioMedia = data.medias?.find(m => m.type === 'audio')
if (!audioMedia?.url) {
throw new Error('La API no devolvió un archivo de audio para esta URL')
}
return {
audioUrl: audioMedia.url,
duracion: data.duration ?? null,
titulo: data.title ?? null,
thumbnail: data.thumbnail ?? null,
plataforma: detectarPlataforma(url),
}
}
function detectarPlataforma(url) {
if (url.includes('tiktok.com')) return 'tiktok'
if (url.includes('instagram.com')) return 'reels'
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'shorts'
return 'tiktok' // fallback
}

7
backend/lib/supabase.js Normal file
View File

@ -0,0 +1,7 @@
import { createClient } from '@supabase/supabase-js'
// Service Role key: bypasea RLS, solo usar en backend
export const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
)

View File

@ -0,0 +1,36 @@
// ============================================================
// TRANSCRIPTOR — OpenAI Whisper
// Descarga el audio desde la URL y lo transcribe
// ============================================================
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
/**
* @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 audioBuffer = await audioResponse.arrayBuffer()
const audioFile = new File([audioBuffer], 'audio.mp3', { type: 'audio/mpeg' })
const transcripcion = await openai.audio.transcriptions.create({
file: audioFile,
model: 'whisper-1',
language: idioma === 'otro' ? undefined : idioma, // auto-detect si es 'otro'
response_format: 'text',
})
if (!transcripcion || transcripcion.trim().length < 10) {
throw new Error('Whisper no pudo transcribir el audio (resultado vacío o muy corto)')
}
return transcripcion.trim()
}

91
backend/lib/validador.js Normal file
View File

@ -0,0 +1,91 @@
// ============================================================
// 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í
// ============================================================
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'])
export const AnalisisSchema = z.object({
// Storytelling
estructura_narrativa: EstructuraEnum,
gancho_tipo: GanchoTipoEnum,
gancho_texto: z.string().min(1).max(200),
gancho_duracion_seg: z.number().int().min(0).max(30),
desarrollo_tipo: DesarrolloEnum,
cta_tipo: CtaTipoEnum,
cta_texto: z.string().max(300).nullable(),
arco_emocional: z.string().min(1).max(200),
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),
// Cialdini
cialdini_reciprocidad: z.boolean(),
cialdini_escasez: z.boolean(),
cialdini_autoridad: z.boolean(),
cialdini_consistencia: z.boolean(),
cialdini_prueba_social: z.boolean(),
cialdini_simpatia: z.boolean(),
cialdini_unidad: z.boolean(),
sesgo_cognitivo: z.string().max(100).nullable(),
trigger_emocional: TriggerEnum,
intensidad_emocional: z.number().int().min(1).max(10),
// Neuropublicidad
atencion_visual: AtencionVisualEnum,
lenguaje_sensorial: z.boolean(),
contraste_narrativo: z.boolean(),
efecto_novedad: z.boolean(),
dolor_placer: DolorPlacerEnum,
personalizacion: z.boolean(),
carga_cognitiva: CargaEnum,
velocidad_locucion: VelocidadEnum,
uso_musica: z.boolean(),
micro_compromisos: z.boolean(),
// Contenido
tema_principal: z.string().min(1).max(100),
angulo_unico: z.string().min(1).max(500),
palabras_clave: z.array(z.string()).min(1).max(10),
tono: TonoEnum,
persona_narradora: PersonaEnum,
promesa_explicita: z.string().min(1).max(500),
nivel_especificidad: EspecificidadEnum,
// Métricas calculadas por GPT-4o
score_virabilidad: z.number().int().min(1).max(100),
resumen_patron: z.string().min(10).max(1000),
})
/**
* Valida el JSON de GPT-4o y lanza error descriptivo si falla
* @param {object} data JSON crudo de GPT-4o
* @returns {object} Datos validados y tipados
*/
export function validarAnalisis(data) {
const resultado = AnalisisSchema.safeParse(data)
if (!resultado.success) {
const errores = resultado.error.errors
.map(e => `${e.path.join('.')}: ${e.message} (recibido: ${JSON.stringify(e.received ?? 'undefined')})`)
.join('\n')
throw new Error(`Validación GPT-4o falló:\n${errores}`)
}
return resultado.data
}