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:
189
backend/api/analizar.js
Normal file
189
backend/api/analizar.js
Normal file
@ -0,0 +1,189 @@
|
||||
// ============================================================
|
||||
// ENDPOINT PRINCIPAL — POST /api/analizar
|
||||
// Orquesta el pipeline completo:
|
||||
// URL → Audio → Whisper → GPT-4o → Validar → Embedding → Supabase
|
||||
// ============================================================
|
||||
import { extraerAudio } from '../lib/extractor.js'
|
||||
import { transcribir } from '../lib/transcriptor.js'
|
||||
import { analizarTranscript } from '../lib/analizador.js'
|
||||
import { validarAnalisis } from '../lib/validador.js'
|
||||
import { generarEmbedding } from '../lib/embeddings.js'
|
||||
import { supabase } from '../lib/supabase.js'
|
||||
|
||||
export default async function handler(req, res) {
|
||||
// Solo aceptar POST
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Método no permitido' })
|
||||
}
|
||||
|
||||
const inicio = Date.now()
|
||||
|
||||
// ── Validar body de entrada ───────────────────────────────
|
||||
const {
|
||||
url,
|
||||
niche,
|
||||
sub_niche,
|
||||
mercado_objetivo,
|
||||
idioma = 'es',
|
||||
cliente_id = null,
|
||||
proyecto_nombre = null,
|
||||
competidor_referente = false,
|
||||
// Métricas manuales (la API no las devuelve)
|
||||
vistas = null,
|
||||
likes = null,
|
||||
compartidos = null,
|
||||
fecha_publicacion = null,
|
||||
} = req.body
|
||||
|
||||
if (!url) return res.status(400).json({ error: 'El campo "url" es requerido' })
|
||||
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
|
||||
|
||||
const URL_SOPORTADAS = /^https?:\/\/(www\.)?(tiktok\.com|vm\.tiktok\.com|instagram\.com|youtube\.com|youtu\.be)/
|
||||
if (!URL_SOPORTADAS.test(url)) {
|
||||
return res.status(400).json({ error: 'URL no soportada. Solo se aceptan TikTok, Instagram Reels y YouTube Shorts.' })
|
||||
}
|
||||
|
||||
let paso = 'inicio'
|
||||
|
||||
try {
|
||||
// ── PASO 1: Extraer audio ─────────────────────────────
|
||||
paso = 'extraccion'
|
||||
const { audioUrl, duracion, titulo, thumbnail, plataforma } = await extraerAudio(url)
|
||||
|
||||
// ── PASO 2: Transcribir con Whisper ───────────────────
|
||||
paso = 'transcripcion'
|
||||
const transcript = await transcribir(audioUrl, idioma)
|
||||
|
||||
// ── PASO 3: Analizar con GPT-4o ───────────────────────
|
||||
paso = 'analisis'
|
||||
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion)
|
||||
|
||||
// ── PASO 4: Validar con Zod ───────────────────────────
|
||||
paso = 'validacion'
|
||||
const analisis = validarAnalisis(analisisRaw)
|
||||
|
||||
// ── PASO 5: Generar embedding vectorial ───────────────
|
||||
paso = 'embedding'
|
||||
const vector = await generarEmbedding(transcript, analisis)
|
||||
|
||||
// ── PASO 6: Guardar en Supabase ───────────────────────
|
||||
paso = 'guardado'
|
||||
const { data: guion, error: errorSupabase } = await supabase
|
||||
.from('guiones')
|
||||
.insert({
|
||||
// Organización
|
||||
cliente_id,
|
||||
niche,
|
||||
sub_niche,
|
||||
mercado_objetivo,
|
||||
idioma,
|
||||
proyecto_nombre,
|
||||
competidor_referente,
|
||||
|
||||
// Metadata del video
|
||||
url_origen: url,
|
||||
plataforma,
|
||||
duracion_segundos: duracion,
|
||||
vistas,
|
||||
likes,
|
||||
compartidos,
|
||||
fecha_publicacion,
|
||||
|
||||
// Análisis de GPT-4o (campos de storytelling)
|
||||
estructura_narrativa: analisis.estructura_narrativa,
|
||||
gancho_tipo: analisis.gancho_tipo,
|
||||
gancho_texto: analisis.gancho_texto,
|
||||
gancho_duracion_seg: analisis.gancho_duracion_seg,
|
||||
desarrollo_tipo: analisis.desarrollo_tipo,
|
||||
cta_tipo: analisis.cta_tipo,
|
||||
cta_texto: analisis.cta_texto,
|
||||
arco_emocional: analisis.arco_emocional,
|
||||
conflicto_central: analisis.conflicto_central,
|
||||
resolucion: analisis.resolucion,
|
||||
pacing_ritmo: analisis.pacing_ritmo,
|
||||
numero_actos: analisis.numero_actos,
|
||||
|
||||
// Cialdini
|
||||
cialdini_reciprocidad: analisis.cialdini_reciprocidad,
|
||||
cialdini_escasez: analisis.cialdini_escasez,
|
||||
cialdini_autoridad: analisis.cialdini_autoridad,
|
||||
cialdini_consistencia: analisis.cialdini_consistencia,
|
||||
cialdini_prueba_social: analisis.cialdini_prueba_social,
|
||||
cialdini_simpatia: analisis.cialdini_simpatia,
|
||||
cialdini_unidad: analisis.cialdini_unidad,
|
||||
sesgo_cognitivo: analisis.sesgo_cognitivo,
|
||||
trigger_emocional: analisis.trigger_emocional,
|
||||
intensidad_emocional: analisis.intensidad_emocional,
|
||||
|
||||
// Neuropublicidad
|
||||
atencion_visual: analisis.atencion_visual,
|
||||
lenguaje_sensorial: analisis.lenguaje_sensorial,
|
||||
contraste_narrativo: analisis.contraste_narrativo,
|
||||
efecto_novedad: analisis.efecto_novedad,
|
||||
dolor_placer: analisis.dolor_placer,
|
||||
personalizacion: analisis.personalizacion,
|
||||
carga_cognitiva: analisis.carga_cognitiva,
|
||||
velocidad_locucion: analisis.velocidad_locucion,
|
||||
uso_musica: analisis.uso_musica,
|
||||
micro_compromisos: analisis.micro_compromisos,
|
||||
|
||||
// Contenido
|
||||
tema_principal: analisis.tema_principal,
|
||||
angulo_unico: analisis.angulo_unico,
|
||||
palabras_clave: analisis.palabras_clave,
|
||||
transcript,
|
||||
tono: analisis.tono,
|
||||
persona_narradora: analisis.persona_narradora,
|
||||
promesa_explicita: analisis.promesa_explicita,
|
||||
nivel_especificidad: analisis.nivel_especificidad,
|
||||
|
||||
// Métricas (score_engagement lo calcula el trigger de Supabase)
|
||||
score_virabilidad: analisis.score_virabilidad,
|
||||
resumen_patron: analisis.resumen_patron,
|
||||
embedding_vector: `[${vector.join(',')}]`,
|
||||
|
||||
// Auditoría
|
||||
procesado_ok: true,
|
||||
version_prompt: 'v1.0',
|
||||
})
|
||||
.select('id, niche, score_virabilidad, resumen_patron')
|
||||
.single()
|
||||
|
||||
if (errorSupabase) {
|
||||
throw new Error(`Supabase error: ${errorSupabase.message}`)
|
||||
}
|
||||
|
||||
const duracionTotal = ((Date.now() - inicio) / 1000).toFixed(1)
|
||||
|
||||
return res.status(200).json({
|
||||
ok: true,
|
||||
guion_id: guion.id,
|
||||
niche: guion.niche,
|
||||
score_virabilidad: guion.score_virabilidad,
|
||||
resumen_patron: guion.resumen_patron,
|
||||
tiempo_total_seg: parseFloat(duracionTotal),
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[analizar] Error en paso "${paso}":`, err.message)
|
||||
|
||||
// Guardar el error en Supabase para diagnóstico
|
||||
if (paso !== 'inicio') {
|
||||
await supabase.from('guiones').insert({
|
||||
url_origen: url,
|
||||
niche,
|
||||
idioma,
|
||||
cliente_id,
|
||||
procesado_ok: false,
|
||||
error_detalle: `[${paso}] ${err.message}`,
|
||||
version_prompt: 'v1.0',
|
||||
}).catch(() => {}) // silencioso si falla el insert de error
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
paso,
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
1289
backend/package-lock.json
generated
Normal file
1289
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
backend/package.json
Normal file
20
backend/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "guiones-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.39.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.1",
|
||||
"express": "^4.18.2",
|
||||
"openai": "^4.28.0",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
190
backend/server.js
Normal file
190
backend/server.js
Normal file
@ -0,0 +1,190 @@
|
||||
import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { extraerAudio } from './lib/extractor.js'
|
||||
import { transcribir } from './lib/transcriptor.js'
|
||||
import { analizarTranscript } from './lib/analizador.js'
|
||||
import { validarAnalisis } from './lib/validador.js'
|
||||
import { generarEmbedding } from './lib/embeddings.js'
|
||||
import { supabase } from './lib/supabase.js'
|
||||
|
||||
// ── Validar variables de entorno requeridas ──────────────────
|
||||
const REQUIRED_ENV = ['OPENAI_API_KEY', 'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'RAPIDAPI_KEY']
|
||||
const missingVars = REQUIRED_ENV.filter(k => !process.env[k])
|
||||
if (missingVars.length > 0) {
|
||||
console.error(`\n❌ Variables de entorno faltantes: ${missingVars.join(', ')}\n Configura el archivo backend/.env antes de iniciar.\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
app.use(cors({ origin: process.env.ALLOWED_ORIGIN || 'http://localhost:5173' }))
|
||||
app.use(express.json())
|
||||
|
||||
// ── GET /api/guiones ────────────────────────────────────────
|
||||
// Lista todos los guiones con paginación y filtros
|
||||
app.get('/api/guiones', async (req, res) => {
|
||||
const { niche, cliente_id, plataforma, page = 1, limit = 20 } = req.query
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
let query = supabase
|
||||
.from('guiones')
|
||||
.select(`
|
||||
id, niche, sub_niche, plataforma, url_origen,
|
||||
gancho_texto, estructura_narrativa, trigger_emocional,
|
||||
tono, score_engagement, score_virabilidad, score_cialdini,
|
||||
fecha_analisis, procesado_ok, vistas, likes, compartidos,
|
||||
tema_principal, resumen_patron
|
||||
`, { count: 'exact' })
|
||||
.eq('procesado_ok', true)
|
||||
.order('fecha_analisis', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (niche) query = query.eq('niche', niche)
|
||||
if (cliente_id) query = query.eq('cliente_id', cliente_id)
|
||||
if (plataforma) query = query.eq('plataforma', plataforma)
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
res.json({ guiones: data, total: count, page: Number(page), limit: Number(limit) })
|
||||
})
|
||||
|
||||
// ── GET /api/guiones/:id ────────────────────────────────────
|
||||
// Detalle completo de un guion
|
||||
app.get('/api/guiones/:id', async (req, res) => {
|
||||
const { data, error } = await supabase
|
||||
.from('guiones')
|
||||
.select('*')
|
||||
.eq('id', req.params.id)
|
||||
.single()
|
||||
|
||||
if (error) return res.status(404).json({ error: 'Guion no encontrado' })
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
// ── GET /api/nichos ─────────────────────────────────────────
|
||||
// Lista de nichos distintos para el selector del formulario
|
||||
app.get('/api/nichos', async (req, res) => {
|
||||
const { data, error } = await supabase
|
||||
.from('guiones')
|
||||
.select('niche')
|
||||
.eq('procesado_ok', true)
|
||||
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
|
||||
const nichos = [...new Set(data.map(r => r.niche))].sort()
|
||||
res.json(nichos)
|
||||
})
|
||||
|
||||
// ── GET /api/clientes ───────────────────────────────────────
|
||||
app.get('/api/clientes', async (req, res) => {
|
||||
const { data, error } = await supabase
|
||||
.from('clientes')
|
||||
.select('id, nombre, industria')
|
||||
.eq('activo', true)
|
||||
.order('nombre')
|
||||
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
// ── GET /api/stats ──────────────────────────────────────────
|
||||
// Estadísticas para el dashboard
|
||||
app.get('/api/stats', async (req, res) => {
|
||||
const { data, error } = await supabase
|
||||
.from('vista_resumen_nichos')
|
||||
.select('*')
|
||||
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
// ── POST /api/analizar ──────────────────────────────────────
|
||||
// Pipeline completo: URL → Audio → Whisper → GPT-4o → Supabase
|
||||
app.post('/api/analizar', async (req, res) => {
|
||||
const {
|
||||
url, niche, sub_niche, mercado_objetivo,
|
||||
idioma = 'es', cliente_id = null, proyecto_nombre = null,
|
||||
competidor_referente = false,
|
||||
vistas = null, likes = null, compartidos = null,
|
||||
fecha_publicacion = null,
|
||||
} = req.body
|
||||
|
||||
if (!url) return res.status(400).json({ error: 'El campo "url" es requerido' })
|
||||
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
|
||||
|
||||
const URL_SOPORTADAS = /^https?:\/\/(www\.)?(tiktok\.com|vm\.tiktok\.com|instagram\.com|youtube\.com|youtu\.be)/
|
||||
if (!URL_SOPORTADAS.test(url)) {
|
||||
return res.status(400).json({ error: 'URL no soportada. Solo se aceptan TikTok, Instagram Reels y YouTube Shorts.' })
|
||||
}
|
||||
|
||||
const inicio = Date.now()
|
||||
let paso = 'inicio'
|
||||
|
||||
try {
|
||||
paso = 'extraccion'
|
||||
console.log(`[1/5] Extrayendo audio de: ${url}`)
|
||||
const { audioUrl, duracion, plataforma } = await extraerAudio(url)
|
||||
|
||||
paso = 'transcripcion'
|
||||
console.log(`[2/5] Transcribiendo audio (${duracion}s)...`)
|
||||
const transcript = await transcribir(audioUrl, idioma)
|
||||
|
||||
paso = 'analisis'
|
||||
console.log(`[3/5] Analizando con GPT-4o...`)
|
||||
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion)
|
||||
|
||||
paso = 'validacion'
|
||||
console.log(`[4/5] Validando schema...`)
|
||||
const analisis = validarAnalisis(analisisRaw)
|
||||
|
||||
paso = 'embedding'
|
||||
console.log(`[5/5] Generando embedding y guardando...`)
|
||||
const vector = await generarEmbedding(transcript, analisis)
|
||||
|
||||
const { data: guion, error: errorSupabase } = await supabase
|
||||
.from('guiones')
|
||||
.insert({
|
||||
cliente_id, niche, sub_niche, mercado_objetivo, idioma,
|
||||
proyecto_nombre, competidor_referente,
|
||||
url_origen: url, plataforma, duracion_segundos: duracion,
|
||||
vistas, likes, compartidos, fecha_publicacion,
|
||||
...analisis,
|
||||
transcript,
|
||||
embedding_vector: `[${vector.join(',')}]`,
|
||||
procesado_ok: true,
|
||||
version_prompt: 'v1.0',
|
||||
})
|
||||
.select('id, niche, score_virabilidad, resumen_patron')
|
||||
.single()
|
||||
|
||||
if (errorSupabase) throw new Error(`Supabase: ${errorSupabase.message}`)
|
||||
|
||||
const segundos = ((Date.now() - inicio) / 1000).toFixed(1)
|
||||
console.log(`✓ Completado en ${segundos}s — ID: ${guion.id}`)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
guion_id: guion.id,
|
||||
niche: guion.niche,
|
||||
score_virabilidad: guion.score_virabilidad,
|
||||
resumen_patron: guion.resumen_patron,
|
||||
tiempo_total_seg: parseFloat(segundos),
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
console.error(`✗ Error en paso "${paso}":`, err.message)
|
||||
await supabase.from('guiones').insert({
|
||||
url_origen: url, niche, idioma, cliente_id,
|
||||
procesado_ok: false,
|
||||
error_detalle: `[${paso}] ${err.message}`,
|
||||
version_prompt: 'v1.0',
|
||||
}).catch(() => {})
|
||||
|
||||
res.status(500).json({ ok: false, paso, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
app.listen(PORT, () => console.log(`Backend local corriendo en http://localhost:${PORT}`))
|
||||
7
backend/vercel.json
Normal file
7
backend/vercel.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"functions": {
|
||||
"api/*.js": {
|
||||
"maxDuration": 60
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user