feat: contexto de video, análisis extendido y métricas sociales

- Campo "Contexto del Video" en formulario de análisis (Paso 03)
  → se pasa a GPT-4o para enriquecer el análisis
- 4 nuevos campos de diagnóstico: fortalezas, debilidades,
  sugerencias_mejora, hashtags_sugeridos (click para copiar)
- Vista de detalle: card de métricas sociales (vistas/likes/compartidos
  con engagement rate calculado)
- Muestra contexto original ingresado por el usuario
- Migración SQL 07: 5 nuevas columnas en tabla guiones
- validador.js: 4 nuevos campos en schema Zod
- server.js + api/analizar.js: acepta y guarda contexto_video

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:44:24 -05:00
parent 2fc4168301
commit be69c0aa48
7 changed files with 173 additions and 11 deletions

View File

@ -33,6 +33,7 @@ export default async function handler(req, res) {
likes = null,
compartidos = null,
fecha_publicacion = null,
contexto_video = '',
} = req.body
if (!url) return res.status(400).json({ error: 'El campo "url" es requerido' })
@ -56,7 +57,7 @@ export default async function handler(req, res) {
// ── PASO 3: Analizar con GPT-4o ───────────────────────
paso = 'analisis'
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion)
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion, contexto_video)
// ── PASO 4: Validar con Zod ───────────────────────────
paso = 'validacion'
@ -136,6 +137,13 @@ export default async function handler(req, res) {
persona_narradora: analisis.persona_narradora,
promesa_explicita: analisis.promesa_explicita,
nivel_especificidad: analisis.nivel_especificidad,
contexto_video: contexto_video || null,
// Diagnóstico
fortalezas: analisis.fortalezas,
debilidades: analisis.debilidades,
sugerencias_mejora: analisis.sugerencias_mejora,
hashtags_sugeridos: analisis.hashtags_sugeridos,
// Métricas (score_engagement lo calcula el trigger de Supabase)
score_virabilidad: analisis.score_virabilidad,

View File

@ -1,7 +1,7 @@
// ============================================================
// ANALIZADOR — GPT-4o
// Prompt maestro multidisciplinario: Storytelling + Cialdini
// + Neuropublicidad + Copywriting → JSON de 45 campos
// + Neuropublicidad + Copywriting → JSON de 49 campos
// ============================================================
import OpenAI from 'openai'
@ -22,12 +22,17 @@ SOLO devuelve el JSON, sin texto adicional, sin markdown, sin explicaciones.`
* @param {string} niche Nicho del video (ej: "fitness", "finanzas")
* @param {string} plataforma tiktok | reels | shorts
* @param {number} duracion Duración en segundos
* @param {string} contextoVideo Contexto adicional opcional sobre el video
* @returns {object} JSON con todos los campos de análisis
*/
export async function analizarTranscript(transcript, niche, plataforma, duracion) {
export async function analizarTranscript(transcript, niche, plataforma, duracion, contextoVideo = '') {
const bloqueContexto = contextoVideo
? `CONTEXTO ADICIONAL DEL VIDEO (úsalo para enriquecer el análisis):\n"""\n${contextoVideo}\n"""\n\n`
: ''
const promptUsuario = `Analiza este video de ${plataforma} de ${duracion} segundos del nicho "${niche}".
TRANSCRIPCIÓN:
${bloqueContexto}TRANSCRIPCIÓN:
"""
${transcript}
"""
@ -88,6 +93,11 @@ Devuelve EXACTAMENTE este JSON con los valores que correspondan:
"ingredientes_clave": ["<elemento 1 que NO puede faltar si se replica este guion>", "<elemento 2>", "<elemento 3>"],
"replicabilidad": "<alta|media|baja>",
"fortalezas": ["<fortaleza 1 del video: qué hace especialmente bien>", "<fortaleza 2>", "<fortaleza 3>"],
"debilidades": ["<debilidad o área de mejora 1>", "<debilidad 2>"],
"sugerencias_mejora": ["<sugerencia accionable concreta 1 para mejorar el video>", "<sugerencia 2>", "<sugerencia 3>"],
"hashtags_sugeridos": ["<hashtag1>", "<hashtag2>", "<hashtag3>", "<hashtag4>", "<hashtag5>", "<hashtag6>", "<hashtag7>"],
"score_virabilidad": <número entero del 1 al 100>,
"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>"
}`

View File

@ -81,6 +81,12 @@ export const AnalisisSchema = z.object({
ingredientes_clave: z.array(z.string()).min(1).max(7),
replicabilidad: ReplicabilidadEnum,
// Diagnóstico y mejora
fortalezas: z.array(z.string()).min(1).max(5),
debilidades: z.array(z.string()).min(1).max(5),
sugerencias_mejora: z.array(z.string()).min(1).max(5),
hashtags_sugeridos: z.array(z.string()).min(1).max(10),
// Métricas
score_virabilidad: z.number().int().min(1).max(100),
resumen_patron: z.string().min(10).max(1500),

View File

@ -111,6 +111,7 @@ app.post('/api/analizar', async (req, res) => {
competidor_referente = false,
vistas = null, likes = null, compartidos = null,
fecha_publicacion = null,
contexto_video = '',
} = req.body
if (!url) return res.status(400).json({ error: 'El campo "url" es requerido' })
@ -135,7 +136,7 @@ app.post('/api/analizar', async (req, res) => {
paso = 'analisis'
console.log(`[3/5] Analizando con GPT-4o...`)
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion)
const analisisRaw = await analizarTranscript(transcript, niche, plataforma, duracion, contexto_video)
paso = 'validacion'
console.log(`[4/5] Validando schema...`)
@ -152,6 +153,7 @@ app.post('/api/analizar', async (req, res) => {
proyecto_nombre, competidor_referente,
url_origen: url, plataforma, duracion_segundos: duracion,
vistas, likes, compartidos, fecha_publicacion,
contexto_video: contexto_video || null,
...analisis,
transcript,
embedding_vector: `[${vector.join(',')}]`,

View File

@ -0,0 +1,17 @@
-- ============================================================
-- MIGRACIÓN 07 — Contexto, Diagnóstico y Hashtags
-- Ejecutar en Supabase SQL Editor después de la migración 06
-- ============================================================
alter table guiones
add column if not exists contexto_video text,
add column if not exists fortalezas text[],
add column if not exists debilidades text[],
add column if not exists sugerencias_mejora text[],
add column if not exists hashtags_sugeridos text[];
comment on column guiones.contexto_video is 'Contexto o idea del video ingresado por el usuario antes del análisis';
comment on column guiones.fortalezas is 'Lo que el video hace especialmente bien (generado por GPT-4o)';
comment on column guiones.debilidades is 'Áreas de mejora detectadas por GPT-4o';
comment on column guiones.sugerencias_mejora is 'Sugerencias accionables para mejorar el video';
comment on column guiones.hashtags_sugeridos is 'Hashtags sugeridos por GPT-4o para maximizar alcance';

View File

@ -99,6 +99,30 @@
</div>
</div>
</section>
<!-- Paso 3: Contexto del Video -->
<section class="space-y-6">
<div class="flex items-center gap-3">
<span class="w-8 h-8 rounded-lg bg-tertiary/10 text-tertiary flex items-center justify-center font-black text-sm border border-tertiary/20">03</span>
<h2 class="text-xl font-headline font-extrabold text-white tracking-tight">Contexto del Video <span class="text-outline font-normal text-base">(opcional)</span></h2>
</div>
<div class="glass-panel p-8 rounded-3xl border border-white/5 shadow-2xl space-y-4">
<p class="text-xs text-outline/70 leading-relaxed">
Describe de qué trata el video, cuál era la intención del creador, o cualquier detalle que la transcripción sola no captura (ej. tono irónico, contexto de tendencia, referencia cultural). GPT-4o usará esto para enriquecer el análisis.
</p>
<div class="relative">
<span class="material-symbols-outlined absolute left-4 top-4 text-tertiary text-lg">lightbulb</span>
<textarea
v-model="form.contexto_video"
rows="4"
placeholder="Ej. Este video es una respuesta a una tendencia viral donde los creadores muestran su rutina matutina. El creador usa humor sarcástico y habla a emprendedores que trabajan desde casa..."
class="w-full bg-surface-container-low border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm focus:ring-2 focus:ring-tertiary/40 focus:border-tertiary/40 text-white placeholder:text-outline/40 transition-all resize-none leading-relaxed"
:disabled="analizando"
></textarea>
</div>
</div>
</section>
</div>
<!-- Columna de Estado del Pipeline -->
@ -176,7 +200,8 @@ const form = ref({
compartidos: null,
cliente_id: null,
proyecto_nombre: '',
competidor_referente: false
competidor_referente: false,
contexto_video: ''
})
const pasosVisibles = [

View File

@ -43,6 +43,34 @@
<!-- Columna izquierda -->
<div class="xl:col-span-4 flex flex-col gap-6">
<!-- Métricas Sociales -->
<div v-if="guion.vistas || guion.likes || guion.compartidos" class="bg-surface-container p-5 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-xs font-headline font-bold text-white mb-4 flex items-center gap-2">
<span class="material-symbols-outlined text-outline text-base">bar_chart</span> Métricas del Video
</h3>
<div class="grid grid-cols-3 gap-3">
<div class="text-center p-3 rounded-2xl bg-surface-container-low border border-white/5">
<span class="material-symbols-outlined text-blue-400 text-lg block mb-1">visibility</span>
<p class="text-[9px] text-outline font-black uppercase tracking-widest mb-1">Vistas</p>
<p class="text-base font-black text-white font-headline">{{ formatNum(guion.vistas) }}</p>
</div>
<div class="text-center p-3 rounded-2xl bg-surface-container-low border border-white/5">
<span class="material-symbols-outlined text-red-400 text-lg block mb-1" style="font-variation-settings:'FILL' 1;">favorite</span>
<p class="text-[9px] text-outline font-black uppercase tracking-widest mb-1">Likes</p>
<p class="text-base font-black text-white font-headline">{{ formatNum(guion.likes) }}</p>
</div>
<div class="text-center p-3 rounded-2xl bg-surface-container-low border border-white/5">
<span class="material-symbols-outlined text-secondary text-lg block mb-1">share</span>
<p class="text-[9px] text-outline font-black uppercase tracking-widest mb-1">Compartidos</p>
<p class="text-base font-black text-white font-headline">{{ formatNum(guion.compartidos) }}</p>
</div>
</div>
<div v-if="guion.score_engagement" class="mt-3 flex items-center justify-between px-3 py-2 rounded-xl bg-surface-container-lowest border border-white/5">
<span class="text-[10px] text-outline font-black uppercase tracking-widest">Engagement Rate</span>
<span class="text-sm font-black text-secondary">{{ (guion.score_engagement * 1).toFixed(2) }}%</span>
</div>
</div>
<!-- Puntaje -->
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-2xl relative overflow-hidden group">
<div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/20 blur-3xl rounded-full group-hover:bg-primary/30 transition-colors pointer-events-none"></div>
@ -59,15 +87,11 @@
<span class="text-xs font-bold text-primary uppercase tracking-widest mt-1">/ 100</span>
</div>
</div>
<div class="grid grid-cols-3 gap-3 pt-6 border-t border-white/5">
<div class="grid grid-cols-2 gap-4 pt-6 border-t border-white/5">
<div class="text-center">
<p class="text-[9px] text-outline uppercase tracking-widest font-bold mb-1">Cialdini</p>
<p class="text-xl font-bold text-white">{{ guion.score_cialdini ?? 0 }}<span class="text-sm text-outline">/7</span></p>
</div>
<div class="text-center">
<p class="text-[9px] text-outline uppercase tracking-widest font-bold mb-1">Engagement</p>
<p class="text-xl font-bold text-secondary">{{ guion.score_engagement ? (guion.score_engagement*1).toFixed(2) + '%' : 'N/A' }}</p>
</div>
<div class="text-center">
<p class="text-[9px] text-outline uppercase tracking-widest font-bold mb-1">Intensidad</p>
<p class="text-xl font-bold text-orange-400">{{ guion.intensidad_emocional || 0 }}<span class="text-sm text-outline">/10</span></p>
@ -281,6 +305,65 @@
</div>
</div>
<!-- Diagnóstico: Fortalezas / Debilidades / Sugerencias -->
<div v-if="guion.fortalezas?.length || guion.debilidades?.length" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-surface-container p-6 rounded-3xl border border-secondary/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-5 flex items-center gap-2">
<span class="material-symbols-outlined text-secondary text-base" style="font-variation-settings:'FILL' 1;">thumb_up</span> Fortalezas
</h3>
<div class="space-y-2">
<div v-for="(f, i) in guion.fortalezas" :key="i" class="flex items-start gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-secondary mt-2 shrink-0"></span>
<p class="text-xs text-on-surface-variant leading-relaxed">{{ f }}</p>
</div>
</div>
</div>
<div class="bg-surface-container p-6 rounded-3xl border border-red-500/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-5 flex items-center gap-2">
<span class="material-symbols-outlined text-red-400 text-base">build</span> Áreas de Mejora
</h3>
<div class="space-y-2 mb-5">
<div v-for="(d, i) in guion.debilidades" :key="i" class="flex items-start gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-red-400 mt-2 shrink-0"></span>
<p class="text-xs text-on-surface-variant leading-relaxed">{{ d }}</p>
</div>
</div>
<div v-if="guion.sugerencias_mejora?.length">
<p class="text-[9px] text-outline font-black uppercase tracking-widest mb-2">Sugerencias</p>
<div class="space-y-1.5">
<div v-for="(s, i) in guion.sugerencias_mejora" :key="i" class="flex items-start gap-2">
<span class="text-[9px] font-black text-tertiary shrink-0 mt-0.5">{{ i + 1 }}.</span>
<p class="text-xs text-on-surface-variant leading-relaxed">{{ s }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Hashtags sugeridos -->
<div v-if="guion.hashtags_sugeridos?.length" class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<h3 class="text-sm font-headline font-bold text-white mb-4 flex items-center gap-2">
<span class="material-symbols-outlined text-outline text-base">tag</span> Hashtags Sugeridos
</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in guion.hashtags_sugeridos"
:key="tag"
class="px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-xs font-bold text-primary cursor-pointer hover:bg-primary/20 transition-colors"
@click="copiarTag(tag)"
>#{{ tag.replace(/^#/, '') }}</span>
</div>
<p class="text-[9px] text-outline/50 mt-3 italic">Haz clic en un hashtag para copiarlo</p>
</div>
<!-- Contexto original del usuario -->
<div v-if="guion.contexto_video" class="bg-surface-container p-5 rounded-2xl border border-tertiary/10">
<p class="text-[10px] text-tertiary font-black uppercase tracking-widest mb-2 flex items-center gap-2">
<span class="material-symbols-outlined text-sm">lightbulb</span> Contexto ingresado
</p>
<p class="text-sm text-on-surface-variant leading-relaxed italic">{{ guion.contexto_video }}</p>
</div>
<!-- Transcripción -->
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
<div class="flex items-center justify-between mb-2">
@ -343,6 +426,17 @@ function openUrl(url) {
if (url) window.open(url, '_blank')
}
function formatNum(n) {
if (!n) return '—'
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
return n.toLocaleString()
}
function copiarTag(tag) {
navigator.clipboard.writeText('#' + tag.replace(/^#/, ''))
}
function plataformaBadge(p) {
return {
tiktok: 'bg-red-500/20 text-red-400',