feat: análisis extendido (10 campos nuevos) + generador de guiones con GPT-4o
Análisis extendido: - Nuevos campos: apertura_exacta, cierre_exacto, tecnica_retencion, momento_pico_seg - Copywriting: nivel_consciencia (Schwartz), objecion_principal, avatar_descripcion - Replicabilidad: ingredientes_clave, replicabilidad, ratio_emocion_logica - analizador.js: prompt extendido con metodología Schwartz + retención - validador.js: schema Zod actualizado con 6 nuevos enums - Migración SQL 05: ALTER TABLE + nuevos ENUMs + índices Generador de guiones: - generador.js: lib GPT-4o con temperatura 0.7 y contexto de patrones - server.js: endpoints POST /api/generar, GET /api/generados, GET /api/generados/:id - backend/api/generar.js + api/generar.js + api/generados.js: Vercel handlers - Migración SQL 06: tabla guiones_generados con score_estimado, variantes, notas - GenerateView.vue: formulario completo + preview del guion con copy al portapapeles - SideNavBar: nueva entrada "Generar" con ícono auto_fix_high - Router: ruta /generate → GenerateView - api.js: api.generar() + api.generados.listar/obtener() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
93
backend/api/generar.js
Normal file
93
backend/api/generar.js
Normal file
@ -0,0 +1,93 @@
|
||||
import { generarGuion } from '../lib/generador.js'
|
||||
import { supabase } from '../lib/supabase.js'
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') return res.status(405).json({ error: 'Método no permitido' })
|
||||
|
||||
const {
|
||||
niche, tema, audiencia, plataforma,
|
||||
duracion_objetivo = 60,
|
||||
tono = 'educativo',
|
||||
objetivo = 'engagement',
|
||||
estructura = 'AIDA',
|
||||
instrucciones_extra = '',
|
||||
cliente_id = null,
|
||||
referencias_ids = [],
|
||||
num_referencias = 3,
|
||||
} = req.body
|
||||
|
||||
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
|
||||
if (!tema) return res.status(400).json({ error: 'El campo "tema" es requerido' })
|
||||
if (!audiencia) return res.status(400).json({ error: 'El campo "audiencia" es requerido' })
|
||||
|
||||
try {
|
||||
let patrones = []
|
||||
|
||||
if (referencias_ids.length > 0) {
|
||||
const { data } = await supabase
|
||||
.from('guiones')
|
||||
.select(`
|
||||
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
|
||||
tecnica_retencion, trigger_emocional, intensidad_emocional,
|
||||
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
|
||||
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
|
||||
ingredientes_clave, resumen_patron, score_virabilidad
|
||||
`)
|
||||
.in('id', referencias_ids)
|
||||
.eq('procesado_ok', true)
|
||||
patrones = data || []
|
||||
} else {
|
||||
let query = supabase
|
||||
.from('guiones')
|
||||
.select(`
|
||||
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
|
||||
tecnica_retencion, trigger_emocional, intensidad_emocional,
|
||||
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
|
||||
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
|
||||
ingredientes_clave, resumen_patron, score_virabilidad
|
||||
`)
|
||||
.eq('procesado_ok', true)
|
||||
.eq('niche', niche)
|
||||
.order('score_virabilidad', { ascending: false })
|
||||
.limit(num_referencias)
|
||||
|
||||
if (plataforma) query = query.eq('plataforma', plataforma)
|
||||
const { data } = await query
|
||||
patrones = data || []
|
||||
}
|
||||
|
||||
const guion = await generarGuion({
|
||||
niche, tema, audiencia, plataforma, duracion_objetivo,
|
||||
tono, objetivo, estructura, instrucciones_extra,
|
||||
}, patrones)
|
||||
|
||||
const { data: guardado, error: errGuardado } = await supabase
|
||||
.from('guiones_generados')
|
||||
.insert({
|
||||
cliente_id, niche, tema, audiencia, plataforma,
|
||||
duracion_objetivo, tono, objetivo,
|
||||
estructura_usada: estructura,
|
||||
instrucciones_extra: instrucciones_extra || null,
|
||||
titulo_sugerido: guion.titulo_sugerido,
|
||||
gancho: guion.gancho,
|
||||
desarrollo: guion.desarrollo,
|
||||
cta: guion.cta,
|
||||
guion_completo: guion.guion_completo,
|
||||
variantes_gancho: guion.variantes_gancho,
|
||||
tecnicas_aplicadas: guion.tecnicas_aplicadas,
|
||||
notas_produccion: guion.notas_produccion,
|
||||
duracion_estimada_seg: guion.duracion_estimada_seg,
|
||||
score_estimado: guion.score_estimado,
|
||||
version_prompt: 'v1.0',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (errGuardado) throw new Error(`Supabase: ${errGuardado.message}`)
|
||||
|
||||
res.json({ ok: true, guion_id: guardado.id, guion })
|
||||
} catch (err) {
|
||||
console.error('[generar]', err.message)
|
||||
res.status(500).json({ ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
// ============================================================
|
||||
// ANALIZADOR — GPT-4o
|
||||
// Prompt maestro multidisciplinario: Storytelling + Cialdini
|
||||
// + Neuropublicidad → JSON de 55 campos analizables
|
||||
// + Neuropublicidad + Copywriting → JSON de 45 campos
|
||||
// ============================================================
|
||||
import OpenAI from 'openai'
|
||||
|
||||
@ -11,6 +11,7 @@ const PROMPT_SISTEMA = `Eres un experto en ingeniería de guiones para video cor
|
||||
- Storytelling y estructura narrativa
|
||||
- Psicología de la persuasión (Cialdini, sesgos cognitivos)
|
||||
- Neuropublicidad y neuromarketing
|
||||
- Copywriting directo (Eugene Schwartz, Gary Halbert, David Ogilvy)
|
||||
- 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.
|
||||
@ -37,6 +38,8 @@ 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>",
|
||||
"apertura_exacta": "<copia EXACTAMENTE las primeras 15 palabras del video tal como aparecen en la transcripción>",
|
||||
"cierre_exacto": "<copia EXACTAMENTE las últimas 10 palabras del video tal como aparecen en la transcripción>",
|
||||
"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>",
|
||||
@ -46,6 +49,8 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
|
||||
"resolucion": "<cómo se resuelve o qué promete resolver>",
|
||||
"pacing_ritmo": "<lento|medio|rapido|variable>",
|
||||
"numero_actos": <1, 2 o 3>,
|
||||
"tecnica_retencion": "<open_loop|cliffhanger|curiosity_gap|countdown|pregunta_abierta|ninguna>",
|
||||
"momento_pico_seg": <segundo estimado donde ocurre el pico emocional o de mayor tensión del video>,
|
||||
|
||||
"cialdini_reciprocidad": <true|false>,
|
||||
"cialdini_escasez": <true|false>,
|
||||
@ -54,7 +59,7 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
|
||||
"cialdini_prueba_social": <true|false>,
|
||||
"cialdini_simpatia": <true|false>,
|
||||
"cialdini_unidad": <true|false>,
|
||||
"sesgo_cognitivo": "<nombre del sesgo cognitivo principal, o null>",
|
||||
"sesgo_cognitivo": "<nombre del sesgo cognitivo principal, ej: FOMO, Efecto Halo, Anclaje, Prueba Social, Reciprocidad, o null>",
|
||||
"trigger_emocional": "<miedo|esperanza|curiosidad|ira|orgullo|tristeza|sorpresa|humor>",
|
||||
"intensidad_emocional": <número entero del 1 al 10>,
|
||||
|
||||
@ -68,6 +73,7 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
|
||||
"velocidad_locucion": "<lenta|normal|rapida|muy_rapida>",
|
||||
"uso_musica": <true|false>,
|
||||
"micro_compromisos": <true|false>,
|
||||
"ratio_emocion_logica": "<emocional|logico|equilibrado>",
|
||||
|
||||
"tema_principal": "<tema en 1-3 palabras>",
|
||||
"angulo_unico": "<qué diferencia a este video de otros del mismo tema, en 1 oración>",
|
||||
@ -76,14 +82,19 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
|
||||
"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>",
|
||||
"nivel_consciencia": "<inconsciente|problema_consciente|solucion_consciente|producto_consciente|mas_consciente>",
|
||||
"objecion_principal": "<la objeción más probable del espectador que el video anticipa o desmonta, en 1 oración, o null si no hay>",
|
||||
"avatar_descripcion": "<describe en 1 oración al perfil de persona a quien está dirigido este video: edad, situación, deseo o dolor principal>",
|
||||
"ingredientes_clave": ["<elemento 1 que NO puede faltar si se replica este guion>", "<elemento 2>", "<elemento 3>"],
|
||||
"replicabilidad": "<alta|media|baja>",
|
||||
|
||||
"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>"
|
||||
"resumen_patron": "<párrafo de 3-4 oraciones describiendo el patrón ganador de este video: qué hace, por qué funciona psicológicamente y cómo se puede replicar>"
|
||||
}`
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.2, // baja temperatura para análisis consistente
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
{ role: 'system', content: PROMPT_SISTEMA },
|
||||
{ role: 'user', content: promptUsuario },
|
||||
@ -95,7 +106,6 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
|
||||
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?/, '')
|
||||
|
||||
102
backend/lib/generador.js
Normal file
102
backend/lib/generador.js
Normal file
@ -0,0 +1,102 @@
|
||||
// ============================================================
|
||||
// GENERADOR — GPT-4o
|
||||
// Crea guiones originales a partir de patrones analizados
|
||||
// ============================================================
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const PROMPT_SISTEMA = `Eres un copywriter experto en video corto viral (TikTok, Reels, YouTube Shorts).
|
||||
Tu especialidad es crear guiones que combinan storytelling, psicología de la persuasión y neuromarketing.
|
||||
Generas guiones originales, concretos y listos para grabar — nunca genéricos ni corporativos.
|
||||
SOLO devuelve el JSON pedido, sin texto adicional, sin markdown, sin explicaciones.`
|
||||
|
||||
/**
|
||||
* Genera un guion nuevo a partir de patrones de referencia
|
||||
* @param {object} params Parámetros de generación
|
||||
* @param {Array} patrones Guiones de referencia del mismo niche
|
||||
* @returns {object} Guion generado con estructura completa
|
||||
*/
|
||||
export async function generarGuion(params, patrones = []) {
|
||||
const {
|
||||
niche,
|
||||
tema,
|
||||
audiencia,
|
||||
plataforma,
|
||||
duracion_objetivo,
|
||||
tono,
|
||||
objetivo,
|
||||
estructura,
|
||||
instrucciones_extra = '',
|
||||
} = params
|
||||
|
||||
// Construir contexto de patrones ganadores
|
||||
const contextPatrones = patrones.length > 0
|
||||
? patrones.map((p, i) => `
|
||||
REFERENCIA ${i + 1} (Score viralidad: ${p.score_virabilidad}/100):
|
||||
- Estructura: ${p.estructura_narrativa} | Gancho tipo: ${p.gancho_tipo}
|
||||
- Técnica de retención: ${p.tecnica_retencion || 'no especificada'}
|
||||
- Trigger emocional: ${p.trigger_emocional} | Intensidad: ${p.intensidad_emocional}/10
|
||||
- Cialdini activos: ${[
|
||||
p.cialdini_reciprocidad && 'Reciprocidad',
|
||||
p.cialdini_escasez && 'Escasez',
|
||||
p.cialdini_autoridad && 'Autoridad',
|
||||
p.cialdini_consistencia && 'Consistencia',
|
||||
p.cialdini_prueba_social && 'Prueba Social',
|
||||
p.cialdini_simpatia && 'Simpatía',
|
||||
p.cialdini_unidad && 'Unidad',
|
||||
].filter(Boolean).join(', ') || 'ninguno'}
|
||||
- Ingredientes clave: ${(p.ingredientes_clave || []).join(' / ')}
|
||||
- Patrón ganador: ${p.resumen_patron}
|
||||
- Apertura que usó: "${p.apertura_exacta || p.gancho_texto}"
|
||||
`).join('\n')
|
||||
: 'No se proporcionaron referencias — crea según las mejores prácticas del nicho.'
|
||||
|
||||
const promptUsuario = `Crea un guion de video para ${plataforma} sobre "${tema}" en el nicho "${niche}".
|
||||
|
||||
PARÁMETROS:
|
||||
- Audiencia objetivo: ${audiencia}
|
||||
- Duración objetivo: ${duracion_objetivo} segundos
|
||||
- Tono: ${tono}
|
||||
- Objetivo principal: ${objetivo}
|
||||
- Estructura narrativa a usar: ${estructura}
|
||||
${instrucciones_extra ? `- Instrucciones adicionales: ${instrucciones_extra}` : ''}
|
||||
|
||||
PATRONES DE ALTO RENDIMIENTO DEL MISMO NICHE (úsalos como inspiración, NO los copies):
|
||||
${contextPatrones}
|
||||
|
||||
Genera el guion con EXACTAMENTE este JSON:
|
||||
|
||||
{
|
||||
"gancho": "<texto del gancho, las primeras 1-3 oraciones que deben capturar la atención en los primeros 3 segundos>",
|
||||
"desarrollo": "<el cuerpo del guion completo, párrafo a párrafo, en el tono y estructura indicados>",
|
||||
"cta": "<el call to action final, específico y claro>",
|
||||
"guion_completo": "<el guion completo de corrido: gancho + desarrollo + cta, listo para leer al grabar>",
|
||||
"duracion_estimada_seg": <número entero estimado de segundos si se narra a ritmo normal>,
|
||||
"titulo_sugerido": "<un título para este guion, máximo 8 palabras>",
|
||||
"tecnicas_aplicadas": ["<técnica 1>", "<técnica 2>", "<técnica 3>"],
|
||||
"notas_produccion": "<2-3 recomendaciones específicas para grabar este video: cámara, edición, música, etc.>",
|
||||
"variantes_gancho": ["<versión alternativa del gancho 1>", "<versión alternativa del gancho 2>"],
|
||||
"score_estimado": <número entero del 1 al 100 estimando el potencial de viralidad de este guion>
|
||||
}`
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.7,
|
||||
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')
|
||||
|
||||
const jsonLimpio = contenido
|
||||
.replace(/^```json\n?/, '')
|
||||
.replace(/^```\n?/, '')
|
||||
.replace(/\n?```$/, '')
|
||||
.trim()
|
||||
|
||||
return JSON.parse(jsonLimpio)
|
||||
}
|
||||
@ -5,25 +5,31 @@
|
||||
// ============================================================
|
||||
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 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'])
|
||||
|
||||
export const AnalisisSchema = z.object({
|
||||
// Storytelling
|
||||
estructura_narrativa: EstructuraEnum,
|
||||
gancho_tipo: GanchoTipoEnum,
|
||||
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),
|
||||
desarrollo_tipo: DesarrolloEnum,
|
||||
cta_tipo: CtaTipoEnum,
|
||||
@ -33,6 +39,8 @@ export const AnalisisSchema = z.object({
|
||||
resolucion: z.string().min(1).max(500),
|
||||
pacing_ritmo: PacingEnum,
|
||||
numero_actos: z.number().int().min(1).max(4),
|
||||
tecnica_retencion: TecnicaRetencionEnum,
|
||||
momento_pico_seg: z.number().int().min(0).max(600),
|
||||
|
||||
// Cialdini
|
||||
cialdini_reciprocidad: z.boolean(),
|
||||
@ -57,8 +65,9 @@ export const AnalisisSchema = z.object({
|
||||
velocidad_locucion: VelocidadEnum,
|
||||
uso_musica: z.boolean(),
|
||||
micro_compromisos: z.boolean(),
|
||||
ratio_emocion_logica: RatioEmocionEnum,
|
||||
|
||||
// Contenido
|
||||
// Contenido + Copywriting
|
||||
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),
|
||||
@ -66,10 +75,15 @@ export const AnalisisSchema = z.object({
|
||||
persona_narradora: PersonaEnum,
|
||||
promesa_explicita: z.string().min(1).max(500),
|
||||
nivel_especificidad: EspecificidadEnum,
|
||||
nivel_consciencia: NivelConcienciaEnum,
|
||||
objecion_principal: z.string().max(500).nullable(),
|
||||
avatar_descripcion: z.string().min(1).max(500),
|
||||
ingredientes_clave: z.array(z.string()).min(1).max(7),
|
||||
replicabilidad: ReplicabilidadEnum,
|
||||
|
||||
// Métricas calculadas por GPT-4o
|
||||
// Métricas
|
||||
score_virabilidad: z.number().int().min(1).max(100),
|
||||
resumen_patron: z.string().min(10).max(1000),
|
||||
resumen_patron: z.string().min(10).max(1500),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@ -6,6 +6,7 @@ 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 { generarGuion } from './lib/generador.js'
|
||||
import { supabase } from './lib/supabase.js'
|
||||
|
||||
// ── Validar variables de entorno requeridas ──────────────────
|
||||
@ -187,4 +188,135 @@ app.post('/api/analizar', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── POST /api/generar ───────────────────────────────────────
|
||||
// Genera un guion nuevo a partir de patrones analizados
|
||||
app.post('/api/generar', async (req, res) => {
|
||||
const {
|
||||
niche, tema, audiencia, plataforma,
|
||||
duracion_objetivo = 60,
|
||||
tono = 'educativo',
|
||||
objetivo = 'engagement',
|
||||
estructura = 'AIDA',
|
||||
instrucciones_extra = '',
|
||||
cliente_id = null,
|
||||
referencias_ids = [], // IDs de guiones analizados a usar como referencia
|
||||
num_referencias = 3, // cuántos guiones top tomar si no se dan IDs explícitos
|
||||
} = req.body
|
||||
|
||||
if (!niche) return res.status(400).json({ error: 'El campo "niche" es requerido' })
|
||||
if (!tema) return res.status(400).json({ error: 'El campo "tema" es requerido' })
|
||||
if (!audiencia) return res.status(400).json({ error: 'El campo "audiencia" es requerido' })
|
||||
|
||||
try {
|
||||
// Obtener patrones de referencia
|
||||
let patrones = []
|
||||
|
||||
if (referencias_ids.length > 0) {
|
||||
// Usar las referencias explícitas del usuario
|
||||
const { data } = await supabase
|
||||
.from('guiones')
|
||||
.select(`
|
||||
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
|
||||
tecnica_retencion, trigger_emocional, intensidad_emocional,
|
||||
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
|
||||
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
|
||||
ingredientes_clave, resumen_patron, score_virabilidad
|
||||
`)
|
||||
.in('id', referencias_ids)
|
||||
.eq('procesado_ok', true)
|
||||
|
||||
patrones = data || []
|
||||
} else {
|
||||
// Auto-seleccionar los mejores del mismo niche y plataforma
|
||||
let query = supabase
|
||||
.from('guiones')
|
||||
.select(`
|
||||
estructura_narrativa, gancho_tipo, gancho_texto, apertura_exacta,
|
||||
tecnica_retencion, trigger_emocional, intensidad_emocional,
|
||||
cialdini_reciprocidad, cialdini_escasez, cialdini_autoridad,
|
||||
cialdini_consistencia, cialdini_prueba_social, cialdini_simpatia, cialdini_unidad,
|
||||
ingredientes_clave, resumen_patron, score_virabilidad
|
||||
`)
|
||||
.eq('procesado_ok', true)
|
||||
.eq('niche', niche)
|
||||
.order('score_virabilidad', { ascending: false })
|
||||
.limit(num_referencias)
|
||||
|
||||
if (plataforma) query = query.eq('plataforma', plataforma)
|
||||
|
||||
const { data } = await query
|
||||
patrones = data || []
|
||||
}
|
||||
|
||||
const guion = await generarGuion({
|
||||
niche, tema, audiencia, plataforma, duracion_objetivo,
|
||||
tono, objetivo, estructura, instrucciones_extra,
|
||||
}, patrones)
|
||||
|
||||
// Guardar en Supabase
|
||||
const { data: guardado, error: errGuardado } = await supabase
|
||||
.from('guiones_generados')
|
||||
.insert({
|
||||
cliente_id,
|
||||
niche, tema, audiencia, plataforma,
|
||||
duracion_objetivo, tono, objetivo,
|
||||
estructura_usada: estructura,
|
||||
instrucciones_extra: instrucciones_extra || null,
|
||||
referencias_ids: referencias_ids.length > 0 ? referencias_ids : (patrones.map ? null : null),
|
||||
titulo_sugerido: guion.titulo_sugerido,
|
||||
gancho: guion.gancho,
|
||||
desarrollo: guion.desarrollo,
|
||||
cta: guion.cta,
|
||||
guion_completo: guion.guion_completo,
|
||||
variantes_gancho: guion.variantes_gancho,
|
||||
tecnicas_aplicadas: guion.tecnicas_aplicadas,
|
||||
notas_produccion: guion.notas_produccion,
|
||||
duracion_estimada_seg: guion.duracion_estimada_seg,
|
||||
score_estimado: guion.score_estimado,
|
||||
version_prompt: 'v1.0',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (errGuardado) throw new Error(`Supabase: ${errGuardado.message}`)
|
||||
|
||||
res.json({ ok: true, guion_id: guardado.id, guion })
|
||||
} catch (err) {
|
||||
console.error('[generar] Error:', err.message)
|
||||
res.status(500).json({ ok: false, error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/generados ──────────────────────────────────────
|
||||
// Lista guiones generados con paginación
|
||||
app.get('/api/generados', async (req, res) => {
|
||||
const { niche, cliente_id, page = 1, limit = 20 } = req.query
|
||||
const offset = (Number(page) - 1) * Number(limit)
|
||||
|
||||
let query = supabase
|
||||
.from('guiones_generados')
|
||||
.select('id, niche, tema, audiencia, plataforma, tono, objetivo, titulo_sugerido, score_estimado, aprobado, fecha_generacion, num_referencias', { count: 'exact' })
|
||||
.order('fecha_generacion', { ascending: false })
|
||||
.range(offset, offset + Number(limit) - 1)
|
||||
|
||||
if (niche) query = query.eq('niche', niche)
|
||||
if (cliente_id) query = query.eq('cliente_id', cliente_id)
|
||||
|
||||
const { data, error, count } = await query
|
||||
if (error) return res.status(500).json({ error: error.message })
|
||||
res.json({ generados: data, total: count, page: Number(page), limit: Number(limit) })
|
||||
})
|
||||
|
||||
// ── GET /api/generados/:id ──────────────────────────────────
|
||||
app.get('/api/generados/:id', async (req, res) => {
|
||||
const { data, error } = await supabase
|
||||
.from('guiones_generados')
|
||||
.select('*')
|
||||
.eq('id', req.params.id)
|
||||
.single()
|
||||
|
||||
if (error) return res.status(404).json({ error: 'Guion generado no encontrado' })
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
app.listen(PORT, () => console.log(`Backend local corriendo en http://localhost:${PORT}`))
|
||||
|
||||
Reference in New Issue
Block a user