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:
106
backend/lib/analizador.js
Normal file
106
backend/lib/analizador.js
Normal 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
40
backend/lib/embeddings.js
Normal 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
54
backend/lib/extractor.js
Normal 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
7
backend/lib/supabase.js
Normal 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
|
||||
)
|
||||
36
backend/lib/transcriptor.js
Normal file
36
backend/lib/transcriptor.js
Normal 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
91
backend/lib/validador.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user