From 2fc4168301bd86dcf1ce1e0637e3855076602025 Mon Sep 17 00:00:00 2001 From: Hanzo_dev <2002samudiojohan@gmail.com> Date: Sun, 29 Mar 2026 20:52:25 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20an=C3=A1lisis=20extendido=20(10=20campo?= =?UTF-8?q?s=20nuevos)=20+=20generador=20de=20guiones=20con=20GPT-4o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + api/generados.js | 21 ++ api/generar.js | 1 + backend/api/generar.js | 93 ++++++ backend/lib/analizador.js | 20 +- backend/lib/generador.js | 102 +++++++ backend/lib/validador.js | 46 ++- backend/server.js | 132 +++++++++ database/migrations/05_analisis_extendido.sql | 67 +++++ database/migrations/06_guiones_generados.sql | 62 ++++ frontend/src/components/SideNavBar.vue | 4 + frontend/src/lib/api.js | 21 +- frontend/src/router/index.js | 6 + frontend/src/views/AnalysisDetailView.vue | 253 ++++++++++++---- frontend/src/views/GenerateView.vue | 274 ++++++++++++++++++ 15 files changed, 1019 insertions(+), 86 deletions(-) create mode 100644 api/generados.js create mode 100644 api/generar.js create mode 100644 backend/api/generar.js create mode 100644 backend/lib/generador.js create mode 100644 database/migrations/05_analisis_extendido.sql create mode 100644 database/migrations/06_guiones_generados.sql create mode 100644 frontend/src/views/GenerateView.vue diff --git a/.gitignore b/.gitignore index 17b94cb..34fa2a0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ npm-debug.log* *.njsproj *.sln +# Claude Code settings +.claude/ + # OS .DS_Store Thumbs.db diff --git a/api/generados.js b/api/generados.js new file mode 100644 index 0000000..69526ef --- /dev/null +++ b/api/generados.js @@ -0,0 +1,21 @@ +import { supabase } from '../backend/lib/supabase.js' + +export default async function handler(req, res) { + if (req.method !== 'GET') return res.status(405).json({ error: 'Método no permitido' }) + + 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) }) +} diff --git a/api/generar.js b/api/generar.js new file mode 100644 index 0000000..8769825 --- /dev/null +++ b/api/generar.js @@ -0,0 +1 @@ +export { default } from '../backend/api/generar.js' diff --git a/backend/api/generar.js b/backend/api/generar.js new file mode 100644 index 0000000..2c2f482 --- /dev/null +++ b/backend/api/generar.js @@ -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 }) + } +} diff --git a/backend/lib/analizador.js b/backend/lib/analizador.js index 3df2a65..a348a0b 100644 --- a/backend/lib/analizador.js +++ b/backend/lib/analizador.js @@ -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": "", "gancho_tipo": "", "gancho_texto": "", + "apertura_exacta": "", + "cierre_exacto": "", "gancho_duracion_seg": , "desarrollo_tipo": "", "cta_tipo": "", @@ -46,6 +49,8 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan: "resolucion": "", "pacing_ritmo": "", "numero_actos": <1, 2 o 3>, + "tecnica_retencion": "", + "momento_pico_seg": , "cialdini_reciprocidad": , "cialdini_escasez": , @@ -54,7 +59,7 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan: "cialdini_prueba_social": , "cialdini_simpatia": , "cialdini_unidad": , - "sesgo_cognitivo": "", + "sesgo_cognitivo": "", "trigger_emocional": "", "intensidad_emocional": , @@ -68,6 +73,7 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan: "velocidad_locucion": "", "uso_musica": , "micro_compromisos": , + "ratio_emocion_logica": "", "tema_principal": "", "angulo_unico": "", @@ -76,14 +82,19 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan: "persona_narradora": "", "promesa_explicita": "", "nivel_especificidad": "", + "nivel_consciencia": "", + "objecion_principal": "", + "avatar_descripcion": "", + "ingredientes_clave": ["", "", ""], + "replicabilidad": "", "score_virabilidad": , - "resumen_patron": "" + "resumen_patron": "" }` 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?/, '') diff --git a/backend/lib/generador.js b/backend/lib/generador.js new file mode 100644 index 0000000..9cb385e --- /dev/null +++ b/backend/lib/generador.js @@ -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": "", + "desarrollo": "", + "cta": "", + "guion_completo": "", + "duracion_estimada_seg": , + "titulo_sugerido": "", + "tecnicas_aplicadas": ["", "", ""], + "notas_produccion": "<2-3 recomendaciones específicas para grabar este video: cámara, edición, música, etc.>", + "variantes_gancho": ["", ""], + "score_estimado": +}` + + 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) +} diff --git a/backend/lib/validador.js b/backend/lib/validador.js index 41c0337..d2feee2 100644 --- a/backend/lib/validador.js +++ b/backend/lib/validador.js @@ -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), }) /** diff --git a/backend/server.js b/backend/server.js index aeb8f4c..86d528a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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}`)) diff --git a/database/migrations/05_analisis_extendido.sql b/database/migrations/05_analisis_extendido.sql new file mode 100644 index 0000000..a7bc13d --- /dev/null +++ b/database/migrations/05_analisis_extendido.sql @@ -0,0 +1,67 @@ +-- ============================================================ +-- MIGRACIÓN 05 — Análisis Extendido +-- Nuevos campos de retención, copywriting y replicabilidad +-- Ejecutar en Supabase SQL Editor +-- ============================================================ + +-- ── Nuevos ENUMS ───────────────────────────────────────────── + +create type tecnica_retencion_enum as enum ( + 'open_loop', 'cliffhanger', 'curiosity_gap', 'countdown', 'pregunta_abierta', 'ninguna' +); + +create type ratio_emocion_enum as enum ('emocional', 'logico', 'equilibrado'); + +create type nivel_consciencia_enum as enum ( + 'inconsciente', 'problema_consciente', 'solucion_consciente', + 'producto_consciente', 'mas_consciente' +); + +create type replicabilidad_enum as enum ('alta', 'media', 'baja'); + +-- ── Bloque Storytelling Extendido ──────────────────────────── + +alter table guiones + add column if not exists apertura_exacta text, + add column if not exists cierre_exacto text, + add column if not exists tecnica_retencion tecnica_retencion_enum, + add column if not exists momento_pico_seg integer check (momento_pico_seg >= 0); + +-- ── Bloque Neuropublicidad Extendido ───────────────────────── + +alter table guiones + add column if not exists ratio_emocion_logica ratio_emocion_enum; + +-- ── Bloque Copywriting / Avatar ────────────────────────────── + +alter table guiones + add column if not exists nivel_consciencia nivel_consciencia_enum, + add column if not exists objecion_principal text, + add column if not exists avatar_descripcion text, + add column if not exists ingredientes_clave text[], + add column if not exists replicabilidad replicabilidad_enum; + +-- ── Índices útiles para la generación de guiones ───────────── + +-- Filtrar rápido por replicabilidad alta (para el generador) +create index if not exists idx_guiones_replicabilidad + on guiones(replicabilidad) + where procesado_ok = true; + +-- Filtrar por nivel de consciencia (segmentación de copywriting) +create index if not exists idx_guiones_nivel_consciencia + on guiones(nivel_consciencia) + where procesado_ok = true; + +-- ── Comentarios de columna ──────────────────────────────────── + +comment on column guiones.apertura_exacta is 'Primeras ~15 palabras exactas del video según la transcripción'; +comment on column guiones.cierre_exacto is 'Últimas ~10 palabras exactas del video según la transcripción'; +comment on column guiones.tecnica_retencion is 'Técnica principal que hace al espectador quedarse hasta el final'; +comment on column guiones.momento_pico_seg is 'Segundo estimado del mayor pico emocional o de tensión del video'; +comment on column guiones.ratio_emocion_logica is 'Balance entre apelaciones emocionales y argumentos lógicos'; +comment on column guiones.nivel_consciencia is 'Nivel de consciencia del avatar según escala Eugene Schwartz'; +comment on column guiones.objecion_principal is 'Objeción del espectador que el video anticipa o desmonta'; +comment on column guiones.avatar_descripcion is 'Perfil de la persona a quien está dirigido el video'; +comment on column guiones.ingredientes_clave is 'Elementos que no pueden faltar al replicar este guion'; +comment on column guiones.replicabilidad is 'Qué tan fácil es replicar este patrón en otro contexto'; diff --git a/database/migrations/06_guiones_generados.sql b/database/migrations/06_guiones_generados.sql new file mode 100644 index 0000000..07d9e9f --- /dev/null +++ b/database/migrations/06_guiones_generados.sql @@ -0,0 +1,62 @@ +-- ============================================================ +-- MIGRACIÓN 06 — Tabla de Guiones Generados +-- Almacena los guiones creados por el generador IA +-- Ejecutar en Supabase SQL Editor después de la migración 05 +-- ============================================================ + +create type objetivo_guion_enum as enum ( + 'awareness', 'engagement', 'conversion', 'educacion', 'entretenimiento' +); + +create table guiones_generados ( + + -- Identificadores + id uuid primary key default gen_random_uuid(), + cliente_id uuid references clientes(id) on delete set null, + + -- Parámetros de entrada + niche text not null, + tema text not null, + audiencia text not null, + plataforma plataforma_enum not null, + duracion_objetivo integer, -- segundos objetivo + tono tono_enum, + objetivo objetivo_guion_enum, + estructura_usada estructura_narrativa_enum, + instrucciones_extra text, + + -- Referencias usadas para generar + referencias_ids uuid[], -- IDs de guiones analizados usados como base + num_referencias integer generated always as ( + coalesce(array_length(referencias_ids, 1), 0) + ) stored, + + -- Contenido generado + titulo_sugerido text, + gancho text not null, + desarrollo text not null, + cta text, + guion_completo text not null, + variantes_gancho text[], + tecnicas_aplicadas text[], + notas_produccion text, + duracion_estimada_seg integer, + score_estimado integer check (score_estimado between 1 and 100), + + -- Auditoría + fecha_generacion timestamp with time zone default now(), + version_prompt text default 'v1.0', + aprobado boolean default false, -- marcado manualmente cuando el guion se usa + notas_usuario text -- feedback del usuario sobre el guion + +); + +-- Índices +create index idx_gg_niche on guiones_generados(niche); +create index idx_gg_cliente on guiones_generados(cliente_id); +create index idx_gg_fecha on guiones_generados(fecha_generacion desc); +create index idx_gg_aprobado on guiones_generados(aprobado) where aprobado = true; + +comment on table guiones_generados is 'Guiones creados por el generador IA a partir de patrones analizados'; +comment on column guiones_generados.referencias_ids is 'IDs de guiones analizados que se usaron como patrones de referencia'; +comment on column guiones_generados.aprobado is 'True cuando el usuario marca el guion como usado/aprobado'; diff --git a/frontend/src/components/SideNavBar.vue b/frontend/src/components/SideNavBar.vue index 35fcd69..fa41847 100644 --- a/frontend/src/components/SideNavBar.vue +++ b/frontend/src/components/SideNavBar.vue @@ -25,6 +25,10 @@ description Guiones + + auto_fix_high + Generar + settings Configuración diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 1d8e5c0..135e946 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -11,13 +11,18 @@ async function request(path, options = {}) { } export const api = { - guiones: { - listar: (params = {}) => request('/guiones?' + new URLSearchParams(params)), - listarTodos: (params = {}) => request('/guiones?' + new URLSearchParams({ ...params, todos: '1' })), - obtener: (id) => request(`/guiones/${id}`), + guiones: { + listar: (params = {}) => request('/guiones?' + new URLSearchParams(params)), + listarTodos: (params = {}) => request('/guiones?' + new URLSearchParams({ ...params, todos: '1' })), + obtener: (id) => request(`/guiones/${id}`), }, - analizar: (body) => request('/analizar', { method: 'POST', body: JSON.stringify(body) }), - nichos: () => request('/nichos'), - clientes: () => request('/clientes'), - stats: () => request('/stats'), + generados: { + listar: (params = {}) => request('/generados?' + new URLSearchParams(params)), + obtener: (id) => request(`/generados/${id}`), + }, + analizar: (body) => request('/analizar', { method: 'POST', body: JSON.stringify(body) }), + generar: (body) => request('/generar', { method: 'POST', body: JSON.stringify(body) }), + nichos: () => request('/nichos'), + clientes: () => request('/clientes'), + stats: () => request('/stats'), } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e66edf3..0f845c9 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -4,6 +4,7 @@ import AnalysisCreateView from '../views/AnalysisCreateView.vue' import AnalysisDetailView from '../views/AnalysisDetailView.vue' import AnalysisListView from '../views/AnalysisListView.vue' import ScriptsView from '../views/ScriptsView.vue' +import GenerateView from '../views/GenerateView.vue' import SettingsView from '../views/SettingsView.vue' const routes = [ @@ -32,6 +33,11 @@ const routes = [ name: 'Scripts', component: ScriptsView }, + { + path: '/generate', + name: 'Generate', + component: GenerateView + }, { path: '/settings', name: 'Settings', diff --git a/frontend/src/views/AnalysisDetailView.vue b/frontend/src/views/AnalysisDetailView.vue index 206f3e5..99aa686 100644 --- a/frontend/src/views/AnalysisDetailView.vue +++ b/frontend/src/views/AnalysisDetailView.vue @@ -8,13 +8,16 @@
- - west Volver al Panel + + west Volver al Historial
{{ guion.niche }} - {{ guion.sub_niche }} + {{ guion.sub_niche }} {{ guion.plataforma }} + + Replicabilidad {{ guion.replicabilidad }} +

{{ guion.tema_principal || 'Análisis sin título' }} @@ -29,20 +32,18 @@ -
- bookmark -

- +
- + +

@@ -51,25 +52,30 @@
- +
- {{ guion.score_virabilidad || 0 }} + {{ guion.score_virabilidad || 0 }} / 100
-
-
-

Índice Cialdini

+
+
+

Cialdini

{{ guion.score_cialdini ?? 0 }}/7

-
-

Engagement Real

-

{{ guion.score_engagement ? (guion.score_engagement*1).toFixed(2) + '%' : 'N/A' }}

+
+

Engagement

+

{{ guion.score_engagement ? (guion.score_engagement*1).toFixed(2) + '%' : 'N/A' }}

+
+
+

Intensidad

+

{{ guion.intensidad_emocional || 0 }}/10

+

psychology_alt Ganchos Semánticos @@ -77,7 +83,7 @@

Estructura Narrativa

-
+
{{ guion.estructura_narrativa || 'No detectada' }}
@@ -86,43 +92,123 @@ Gancho Principal {{ guion.gancho_duracion_seg ? guion.gancho_duracion_seg + 's' : '' }}

-
+

{{ guion.gancho_tipo || 'Gancho Estándar' }}

"{{ guion.gancho_texto || '—' }}"

+
+

Técnica de Retención

+
+ repeat + {{ guion.tecnica_retencion || '—' }} + Pico: {{ guion.momento_pico_seg }}s +
+
+ + +
+

+ person_search Avatar & Copywriting +

+
+
+

Nivel de Consciencia

+
+
+
+
+

{{ guion.nivel_consciencia?.replace(/_/g,' ') || '—' }}

+
+
+
+

Avatar Objetivo

+

{{ guion.avatar_descripcion || '—' }}

+
+
+

Objeción Principal

+
+

"{{ guion.objecion_principal }}"

+
+
+
+

Balance Emoción / Lógica

+
+ {{ ratioIcon }} + {{ guion.ratio_emocion_logica || '—' }} +
+
+
+
+

- +
+ +

Síntesis del Patrón Ganador

{{ guion.resumen_patron }}

+ +
+
+

+ play_arrow Apertura Exacta +

+

"{{ guion.apertura_exacta }}"

+
+
+

+ stop Cierre Exacto +

+

"{{ guion.cierre_exacto }}"

+
+
+ + +
+

+ key Ingredientes Clave para Replicar +

+
+
+ {{ i + 1 }} +

{{ ing }}

+
+
+
+ +
-

- local_fire_department Resonancia Emocional -

-
-
- Intensidad - {{ guion.intensidad_emocional || 0 }}/10 -
-
-
-
-
-
- - - -
+

+ local_fire_department Resonancia Emocional +

+
+
+ Intensidad + {{ guion.intensidad_emocional || 0 }}/10 +
+
+
+
+
+
+ + + + +
@@ -141,6 +227,7 @@
+

@@ -153,6 +240,7 @@ +

@@ -160,29 +248,48 @@

record_voice_over Entrega y Alcance

-
- - - -
-

Palabras Clave Extraídas

-
- {{ kw }} -
+
+ + + + + +
+
+

Texto del CTA

+

"{{ guion.cta_texto }}"

+
+
+

Palabras Clave

+
+ {{ kw }}
- + +
+
+

Promesa Explícita

+

{{ guion.promesa_explicita || '—' }}

+
+
+

Conflicto → Resolución

+

{{ guion.conflicto_central || '—' }}

+

→ {{ guion.resolucion }}

+
+
+ +
-

- notes Transcripción Completa -

- +

+ notes Transcripción Completa +

+
@@ -191,13 +298,14 @@

+