diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 081c0c3..2687674 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,438 +1,230 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; -import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; -import { isAgencyRole, userRole } from '@/lib/agency'; -import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics'; -import { - SimChannel, - simulateFromTenantMetrics, -} from '@/lib/simulate'; +import { resolveActiveTenant } from '@/lib/tenant-context'; +import { buildClientAssistantContext } from '@/lib/assistant-context'; +import Anthropic from '@anthropic-ai/sdk'; + +export const dynamic = 'force-dynamic'; export async function POST(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; - // Prefer user JWT for tenant-scoped reads; service role only if explicitly set (server) const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; + const anthropicKey = + process.env.ANTHROPIC_API_KEY?.trim() || + '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'; + // 1. Autenticación de usuario const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // 2. Contexto de tenant activo const ctx = await resolveActiveTenant(user); - const agency = isAgencyRole(userRole(user)); - // Never fall back to agency home tenant while "vista global" — that mixes clients. const tenantId = ctx.tenantId; const companyName = ctx.label || user.user_metadata?.company || 'Cliente'; - if (!tenantId && !ctx.portfolioWide) { - return NextResponse.json( - { error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' }, - { status: 403 } - ); - } - // 1. Extraer mensaje del usuario + + // 3. Extraer consulta y mensajes del historial let userMessage = ''; + let incomingMessages: Array<{ role: string; content: string }> = []; + try { const body = await req.json(); if (typeof body.message === 'string' && body.message.trim()) { userMessage = body.message.trim(); } else if (typeof body.prompt === 'string' && body.prompt.trim()) { userMessage = body.prompt.trim(); - } else if (Array.isArray(body.messages) && body.messages.length > 0) { - const userMsgs = body.messages.filter((m: any) => m.role === 'user'); - if (userMsgs.length > 0) { - userMessage = userMsgs[userMsgs.length - 1].content || ''; - } } - } catch (e) { - console.error('Error parsing chat body:', e); - } - const normQuery = userMessage - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[¿?¡!.,;:_()]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - // Use service role for RPC only after authn; still filter by tenant_id in every call - const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ''; - const headers = { - apikey: keyForRpc, - Authorization: `Bearer ${keyForRpc}`, - 'Content-Type': 'application/json' - }; - - // Helper para devolver respuesta uniforme - const reply = (text: string) => { - return NextResponse.json([{ text }]); - }; - - // 3. Intención: Saludos, Capacidades y "¿Qué puedo preguntar?" - const isHelpQuery = - normQuery === '' || - normQuery === 'hola' || - normQuery === 'buenas' || - normQuery === 'buenos dias' || - normQuery === 'buenas tardes' || - normQuery === 'hey' || - normQuery.includes('que puedo preguntar') || - normQuery.includes('que te puedo preguntar') || - normQuery.includes('que preguntar') || - normQuery.includes('que sabes') || - normQuery.includes('que puedes hacer') || - normQuery.includes('quien eres') || - normQuery.includes('ayuda') || - normQuery.includes('ejemplos') || - normQuery.includes('comandos') || - normQuery.includes('opciones') || - normQuery.includes('como funciona el asistente') || - normQuery.includes('instrucciones'); - - if (isHelpQuery) { - if (agency && ctx.portfolioWide) { - return reply( - 'Selecciona un **cliente en Contexto** para usar el asistente con los datos de esa cuenta. En vista global no mezclo información entre clientes.' + if (Array.isArray(body.messages)) { + incomingMessages = body.messages.filter( + (m: any) => + m && + (m.role === 'user' || m.role === 'assistant') && + typeof m.content === 'string' && + m.content.trim().length > 0 ); } - return reply( - `👋 **¡Hola! Soy tu Asistente Financiero & de Crecimiento de 4Ventures** para **${companyName}**.\n\n` + - `Estoy conectado directamente a tu base de datos analítica y ejecuto cálculos matemáticos deterministas (temperatura 0.0, sin alucinaciones). Aquí tienes todo lo que puedes preguntarme:\n\n` + - `1. **🔮 Simulaciones de Inversión ("¿Qué pasa si...?"):**\n` + - ` - *«¿Qué pasa si invierto 1.000 € en Meta?»*\n` + - ` - *«Simular 2.500 € en Google Ads»*\n` + - ` - *«Proyección con 5.000 € de presupuesto»*\n` + - ` *(Calcula retornos p10 conservador, p50 esperado y p90 optimista según tu histórico real).* \n\n` + - `2. **🎯 Objetivos y Cumplimiento Mensual:**\n` + - ` - *«¿Cómo van los objetivos de este mes?»*\n` + - ` - *«¿Cuánto llevamos facturado vs la meta?»*\n` + - ` - *«Progreso de captación de leads»*\n\n` + - `3. **🔄 Embudo Comercial y Conversión:**\n` + - ` - *«Muéstrame el embudo comercial»*\n` + - ` - *«¿Cuál es nuestra tasa de cierre lead a venta?»*\n` + - ` - *«Ticket medio de los clientes ganados»*\n\n` + - `4. **📊 Métricas CAC y ROAS por Canal:**\n` + - ` - *«¿Cuál es el CAC y ROAS de los últimos 30 días?»*\n` + - ` - *«Rendimiento por canal publicitario»*\n\n` + - `5. **🏆 Ranking de Campañas:**\n` + - ` - *«¿Cuáles son las mejores campañas activas?»*\n` + - ` - *«¿Dónde estamos invirtiendo más?»*\n\n` + - `6. **📖 Metodología y Glosario 4Ventures:**\n` + - ` - *«¿Qué diferencia hay entre CAC de cohorte y CAC de caja?»*\n` + - ` - *«¿Cómo funciona la atribución universal a 90 días?»*\n\n` + - `💡 *Puedes hacer clic en cualquiera de los botones rápidos de abajo o escribir directamente tu consulta.*` - ); - } - - if (ctx.portfolioWide || !tenantId) { - return reply( - 'Selecciona un **cliente en Contexto** para consultar sus datos (calendario, redes, KPIs). En vista global no mezclo información entre cuentas.' - ); - } - - // 4. Intención: Simulación Determinista "¿Qué pasa si...?" - if ( - normQuery.includes('simul') || - normQuery.includes('invierto') || - normQuery.includes('pasa si') || - normQuery.includes('proyec') || - normQuery.includes('presupuesto') - ) { - try { - if (ctx.portfolioWide || !tenantId) { - return reply( - 'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).' - ); - } - - const matchNumber = userMessage.match(/(\d+[\d\.,]*)/); - let amount = 1000; - if (matchNumber) { - amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.')); - } - - let channel: SimChannel = 'blended'; - if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta'; - else if (normQuery.includes('google')) channel = 'google'; - else if (normQuery.includes('tiktok')) { - return reply( - 'TikTok aún no tiene campañas activas en este portafolio. Puedo simular con **Meta**, **Google** o presupuesto **blended** según el histórico del cliente.' - ); - } - - let daily: AdDailyRow[] = []; - let weekly: KpiWeeklyRow[] = []; - if (agency && ctx.viaView) { - const tid = encodeURIComponent(tenantId); - const [d, w] = await Promise.all([ - restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400` - ), - restGet( - `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52` - ), - ]); - daily = d.data || []; - weekly = w.data || []; - } else { - const [{ data: d }, { data: w }] = await Promise.all([ - supabase - .from('ad_daily') - .select('*') - .order('date', { ascending: false }) - .limit(400), - supabase - .from('kpi_weekly') - .select('*') - .order('week', { ascending: false }) - .limit(52), - ]); - daily = (d as AdDailyRow[]) || []; - weekly = (w as KpiWeeklyRow[]) || []; - } - - const sim = simulateFromTenantMetrics({ - spend: Number.isFinite(amount) && amount > 0 ? amount : 1000, - channel, - daily, - weekly, - }); - const p10 = sim.conservative_p10; - const p50 = sim.expected_p50; - const p90 = sim.optimistic_p90; - const base = sim.baseline; - const channelLabel = (sim.target_channel || 'blended').toUpperCase(); - const text = - `🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` + - `📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` + - `- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` + - `- **Tasa de cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` + - `- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` + - `- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` + - `📊 **Proyección:**\n` + - `- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.roas}x**)\n` + - `- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` + - `- **Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p90.roas}x**)\n\n` + - `💡 *Estimación con métricas reales del cliente en contexto. Sin TikTok (sin campañas activas).*`; - - return reply(text); - } catch (e) { - console.error('Error in tenant simulate chat:', e); - } - } - - // 5. Intención: Objetivos vs Real / Estrategia - if ( - normQuery.includes('objetivo') || - normQuery.includes('meta') || - normQuery.includes('factura') || - normQuery.includes('target') || - normQuery.includes('cumplimiento') - ) { - try { - const now = new Date(); - const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, { - method: 'POST', - headers, - body: JSON.stringify({ - p_month: currentMonth, - p_tenant_id: tenantId, - }), - }); - - if (res.ok) { - const data = await res.json(); - const t = data[0] || {}; - const revAct = Number(t.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 }); - const revTar = Number(t.revenue_target || 0).toLocaleString('es-ES'); - const pct = t.revenue_achievement_pct || 0; - - const text = - `🎯 **Cumplimiento de Objetivos del Mes (${currentMonth}):**\n\n` + - `- **Facturación:** **${revAct} €** logrados de una meta de **${revTar} €** (${pct}% completado).\n` + - `- **Leads Captados:** **${t.leads_actual || 0}** / ${t.leads_target || 0} objetivo.\n` + - `- **Clientes Ganados:** **${t.clients_actual || 0}** / ${t.clients_target || 0} objetivo.\n\n` + - `📌 **Diagnóstico:** ${pct >= 100 ? '🎉 ¡Objetivo de facturación alcanzado!' : pct >= 50 ? '⚡ Buen ritmo de avance en el periodo.' : '⚠️ Requiere acelerar el volumen de leads calificados o mejorar tasa de contacto.'}\n\n` + - `*Fuente: RPC targets_vs_actual sincronizado con deals ganados en CRM.*`; - - return reply(text); - } - } catch (e) { - console.error('Error in targets_vs_actual:', e); - } - } - - // 6. Intención: Embudo / Funnel / Conversión - if ( - normQuery.includes('funnel') || - normQuery.includes('embudo') || - normQuery.includes('tasa') || - normQuery.includes('ticket') || - normQuery.includes('conversion') || - normQuery.includes('cierre') - ) { - try { - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/lead_funnel`, { - method: 'POST', - headers, - body: JSON.stringify({ p_tenant_id: tenantId, p_days: 30 }) - }); - - if (res.ok) { - const f = await res.json(); - const text = - `🔄 **Embudo de Conversión Comercial (Últimos 30 días):**\n\n` + - `1. **Leads Inbound Captados:** ${f.total_leads || 0}\n` + - `2. **Leads Contactados:** ${f.contacted_leads || 0}\n` + - `3. **Oportunidades en Pipeline:** ${f.deals_in_pipeline || 0}\n` + - `4. **Ventas Cerradas Ganadas:** **${f.deals_won || 0}**\n\n` + - `💰 **Resultados Financieros:**\n` + - `- **Facturación Cerrada:** **${Number(f.revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €**\n` + - `- **Ticket Medio:** **${Number(f.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €**\n` + - `- **Tasa de Cierre Lead ➔ Venta:** **${f.conversion_lead_to_deal_pct || 0}%**\n\n` + - `*Fuente: Webhook deal-won n8n y tabla deals en PostgreSQL.*`; - - return reply(text); - } - } catch (e) { - console.error('Error in lead_funnel:', e); - } - } - - // 7. Intención: Ranking de Campañas / Anuncios - if ( - normQuery.includes('campa') || - normQuery.includes('anuncio') || - normQuery.includes('ranking') || - normQuery.includes('mejor') || - normQuery.includes('ads') - ) { - try { - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/rank_campaigns`, { - method: 'POST', - headers, - body: JSON.stringify({ p_tenant_id: tenantId, p_metric: 'spend', p_limit: 5 }) - }); - - if (res.ok) { - const campaigns = await res.json(); - if (Array.isArray(campaigns) && campaigns.length > 0) { - const list = campaigns.map((c: any, i: number) => - `${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**` - ).join('\n\n'); - - return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`); - } else { - return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.'); - } - } - } catch (e) { - console.error('Error in rank_campaigns:', e); - } - } - - // 8. Intención: RAG Documental Híbrido (Biblioteca de Activos) - if ( - normQuery.includes('document') || - normQuery.includes('copy') || - normQuery.includes('informe') || - normQuery.includes('brief') || - normQuery.includes('activo') || - normQuery.includes('creativo') - ) { - try { - const res = await fetch(`${supabaseUrl}/rest/v1/rpc/match_documents`, { - method: 'POST', - headers, - body: JSON.stringify({ - p_tenant_id: tenantId, - p_query_text: userMessage, - p_match_count: 3 - }) - }); - - if (res.ok) { - const matches = await res.json(); - if (Array.isArray(matches) && matches.length > 0) { - const excerpts = matches.map((m: any) => - `📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}` - ).join('\n\n'); - - return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`); - } - } - - // Si no hay chunks en RAG, buscar documentos aprobados directamente - const docRes = await fetch(`${supabaseUrl}/rest/v1/documents?tenant_id=eq.${tenantId}&status=eq.approved&select=id,title,type,content`, { headers }); - if (docRes.ok) { - const docs = await docRes.json(); - if (Array.isArray(docs) && docs.length > 0) { - const docList = docs.map((d: any) => `- **${d.title}** (${d.type.toUpperCase()})`).join('\n'); - return reply(`📁 **Documentos Aprobados Disponibles en tu Biblioteca:**\n\n${docList}\n\nPuedes consultarlos en detalle en la pestaña **Biblioteca de Activos**.`); - } - } - } catch (e) { - console.error('Error in match_documents chat:', e); - } - } - - // 9. Intención: Metodología y Glosario 4Ventures - if ( - normQuery.includes('que es cac') || - normQuery.includes('cohorte') || - normQuery.includes('caja') || - normQuery.includes('atribucion') || - normQuery.includes('90 dias') || - normQuery.includes('metodologia') - ) { - return reply( - `📖 **Metodología Oficial de Crecimiento — 4Ventures:**\n\n` + - `1. **CAC de Cohorte (Cohort CAC):**\n` + - ` Inversión en Ads del periodo dividida entre los clientes ganados que *hicieron clic por primera vez en ese mismo periodo*, sin importar si tardaron 15 o 60 días en comprar. Es la única métrica real para saber si tus campañas son rentables.\n\n` + - `2. **CAC de Caja (Cash CAC):**\n` + - ` Inversión total del mes dividida entre los clientes cerrados ese mismo mes. Sirve para controlar el flujo de tesorería mensual.\n\n` + - `3. **Atribución Universal a 90 Días (` + '`_4v_attr.js`' + `):**\n` + - ` Captura los Click IDs (` + '`fbclid`' + `, ` + '`gclid`' + `) y UTMs en el primer contacto del visitante y los mantiene 90 días en el navegador, inyectándolos en formularios y enlaces de WhatsApp.\n\n` + - `4. **Cierre de Bucle CRM (` + '`deal-won`' + `):**\n` + - ` Cuando tu equipo de ventas marca un cliente como "Ganado", n8n recupera el origen original y recalcula el ROAS al instante.` - ); - } - - // 10. Consulta de KPIs Semanales / CAC & ROAS (Default Analítico) - try { - const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?tenant_id=eq.${tenantId}&select=*`, { headers }); - if (res.ok) { - const kpis = await res.json(); - if (Array.isArray(kpis) && kpis.length > 0) { - const channelsSummary = kpis.map((k: any) => - `- Canal **${(k.channel || 'Directo').toUpperCase()}**:\n` + - ` - Inversión: **${Number(k.total_spend || 0).toFixed(2)} €**\n` + - ` - Leads Captados: **${k.total_leads || 0}**\n` + - ` - Clientes Ganados: **${k.cohort_deals_won || 0}**\n` + - ` - Facturación Cohorte: **${Number(k.cohort_revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €**\n` + - ` - CAC Cohorte: **${Number(k.cac_cohort || 0).toFixed(2)} €**\n` + - ` - ROAS Cohorte: **${Number(k.roas_cohort || 0).toFixed(2)}x**` - ).join('\n\n'); - - return reply( - `📊 **Rendimiento Analítico de ${companyName} (Corte Horario):**\n\n` + - `${channelsSummary}\n\n` + - `💡 *Puedes consultarme simulaciones ("¿Qué pasa si invierto 1000€ en Meta?"), metas de facturación o el embudo de ventas.*` - ); - } - } } catch (e) { - console.error('Error fetching KPIs for assistant:', e); + console.error('Error parsing chat request body:', e); } - return reply( - `📊 **Centro de Analítica — ${companyName}:**\n\n` + - `No he detectado una consulta numérica específica sobre tu pregunta, pero puedo proporcionarte métricas en tiempo real:\n\n` + - `- Escribe *«Objetivos»* para ver las metas de facturación vs ventas reales.\n` + - `- Escribe *«Simular 1000€ en Meta»* para proyectar nuevos clientes con intervalos p10/p50/p90.\n` + - `- Escribe *«Embudo»* para ver la tasa de conversión y ticket medio.\n` + - `- Escribe *«Campañas»* para ver los anuncios con mayor inversión.` - ); + if (!userMessage && incomingMessages.length > 0) { + const lastUser = [...incomingMessages].reverse().find((m) => m.role === 'user'); + if (lastUser) userMessage = lastUser.content; + } + + if (!userMessage.trim()) { + return NextResponse.json([ + { + text: 'Por favor, escribe una pregunta sobre las finanzas, objetivos, campañas o estrategia del cliente.', + }, + ]); + } + + // 4. Caso: Vista Global de Agencia (sin tenant seleccionado) + if (ctx.portfolioWide || !tenantId) { + // Si están en vista global, Claude responde explicando el portafolio y pidiendo seleccionar un cliente + try { + const client = new Anthropic({ apiKey: anthropicKey }); + const prompt = `El usuario está en el Centro de Mando de 4Ventures en la "Vista Global de Agencia" (sin un cliente específico seleccionado). +Pregunta del usuario: "${userMessage}" + +Instrucciones: +1. Explica amablemente que para ver métricas exactas (CAC, ROAS, facturación, targets de mes, campañas o calendario de un negocio en particular), debe seleccionar el cliente en el desplegable de "Contexto" en la barra lateral. +2. Si la pregunta es sobre la metodología de 4Ventures (ej. qué es el CAC de cohorte vs CAC de caja, atribución universal a 90 días con _4v_attr.js, simulaciones p10/p50/p90, o Agency OS), respóndela con total claridad y maestría técnica. +3. Sé conciso, profesional y ejecutivo en español.`; + + const response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250929', + max_tokens: 1500, + messages: [{ role: 'user', content: prompt }], + }); + + const replyText = response.content + .map((b) => (b.type === 'text' ? b.text : '')) + .join(''); + + return NextResponse.json([{ text: replyText }]); + } catch (e: any) { + return NextResponse.json([ + { + text: 'Selecciona un **cliente en Contexto** para consultar sus datos específicos (finanzas, KPIs, calendario y marca). En vista global no mezclo información entre cuentas.', + }, + ]); + } + } + + // 5. Caso: Cliente específico activo -> Cargar TODO el contexto del cliente + let clientContext; + try { + clientContext = await buildClientAssistantContext( + tenantId, + serviceKey, + supabaseUrl + ); + } catch (err: any) { + console.error('Error cargando contexto del cliente para Claude:', err); + } + + const clientDataText = clientContext?.contextPrompt || `Cliente: ${companyName}`; + const baseline = clientContext?.baseline || { + historicalCpl: 25, + conversionRatePct: 10, + avgTicket: 200, + sampleSpend: 0, + }; + + // 6. Preparar System Prompt para Claude + const systemPrompt = `Eres el Asistente IA Financiero y de Crecimiento de 4Ventures para el cliente "${companyName}". +Tu misión es actuar como el Director Financiero (CFO) y Director de Crecimiento (Head of Growth) de la marca. +Tienes acceso total, verificado y en tiempo real a TODA la información del cliente almacenada en la base de datos de 4Ventures. + +${clientDataText} + +============================================================ +REGLAS Y PAUTAS INMUTABLES: +============================================================ +1. RIGOR Y VERACIDAD DE DATOS (CERO ALUCINACIONES): + - Basa todas tus afirmaciones y cifras en los datos reales del cliente inyectados arriba. + - Cita números exactos cuando hables de facturación, tickets, leads, CAC, ROAS o cumplimiento de metas. + - Si se consulta por un dato que no consta en los registros del cliente (ej. una red social en la que aún no se han publicado campañas o un mes sin metas definidas), dilo explícitamente sin inventar. + +2. FINANZAS Y CÁLCULOS MATEMÁTICOS: + - CAC de Cohorte: Inversión en Ads ÷ Clientes ganados de ese primer clic (métrica real de rentabilidad). + - CAC de Caja: Inversión mensual ÷ Clientes ganados en el mes (control de tesorería). + - ROAS: Facturación atribuida ÷ Inversión publicitaria. + - Tasa de Conversión: (Ventas ganadas ÷ Leads totales) × 100. + - Ticket Medio: Facturación total ÷ Ventas ganadas. + +3. SIMULACIONES PREDICTIVAS ("¿Qué pasa si invierto X € en Meta/Google?"): + - Si el usuario pide una simulación o proyección con un presupuesto, utiliza el baseline real del cliente: + * CPL Histórico: ${baseline.historicalCpl > 0 ? `${baseline.historicalCpl.toFixed(2)} €` : 'según datos'} + * Tasa de Cierre: ${baseline.conversionRatePct > 0 ? `${baseline.conversionRatePct.toFixed(1)} %` : 'según datos'} + * Ticket Medio: ${baseline.avgTicket > 0 ? `${baseline.avgTicket.toFixed(2)} €` : 'según datos'} + - Presenta siempre los 3 escenarios deterministas: + a) Escenario Esperado (p50): promedios históricos. + b) Escenario Conservador (p10): menor eficiencia (~20-25% más de CPL, menor tasa de cierre). + c) Escenario Optimista (p90): rendimiento de mejores campañas (~15-20% menor CPL, mayor cierre). + - Concluye con el número proyectado de leads, nuevos clientes, facturación esperada y ROAS resultante. + +4. PLATAFORMA DE MARCA Y CONTENIDOS: + - Conoce a fondo la propuesta de valor, los arquetipos, la voz (Do's y Don'ts), el ICP y las objeciones del cliente. + - Si el usuario te pide ideas de copys, ganchos (hooks) o publicaciones, redacta alineándote al 100% con las directrices de la Plataforma de Marca y el calendario editorial. + +5. FORMATO DE RESPUESTA: + - Estilo analítico, ejecutivo, directo y proactivo. + - Emplea formato Markdown limpio: negritas para datos cruciales, listas estructuradas con viñetas, tablas cuando faciliten comparar periodos/canales, y citas destacadas ('>') para recomendaciones estratégicas.`; + + // 7. Sanitizar y estructurar historial de mensajes para la API de Claude + const anthropicMessages: Array<{ role: 'user' | 'assistant'; content: string }> = []; + + // Agregar mensajes previos del chat asegurando alternancia + for (const m of incomingMessages) { + const role = m.role === 'user' ? 'user' : 'assistant'; + const last = anthropicMessages[anthropicMessages.length - 1]; + if (last && last.role === role) { + last.content += `\n\n${m.content}`; + } else { + anthropicMessages.push({ role, content: m.content }); + } + } + + // Asegurar que comience con un mensaje 'user' + if (anthropicMessages.length === 0 || anthropicMessages[0].role !== 'user') { + anthropicMessages.unshift({ role: 'user', content: userMessage }); + } + + // Si el último mensaje del historial no es el actual, añadirlo + const lastMsg = anthropicMessages[anthropicMessages.length - 1]; + if (lastMsg.role !== 'user') { + anthropicMessages.push({ role: 'user', content: userMessage }); + } else if (!lastMsg.content.includes(userMessage)) { + lastMsg.content = userMessage; + } + + // 8. Llamar a Claude + try { + const client = new Anthropic({ apiKey: anthropicKey }); + + let response; + try { + response = await client.messages.create({ + model: 'claude-sonnet-4-5-20250929', + max_tokens: 3000, + temperature: 0.2, + system: systemPrompt, + messages: anthropicMessages, + }); + } catch (sonnetErr: any) { + console.warn('Error en claude-sonnet-4-5, probando fallback a claude-haiku-4-5:', sonnetErr?.message); + response = await client.messages.create({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 2500, + temperature: 0.2, + system: systemPrompt, + messages: anthropicMessages, + }); + } + + const replyText = response.content + .map((b) => (b.type === 'text' ? b.text : '')) + .join(''); + + return NextResponse.json([{ text: replyText }]); + } catch (apiErr: any) { + console.error('Error al consultar la API de Anthropic Claude:', apiErr); + return NextResponse.json( + [ + { + text: `⚠️ **Aviso del Asistente:** Hubo una dificultad al conectar con el motor Claude (${apiErr?.message || 'Error de conexión'}). Por favor, reintenta tu pregunta en unos momentos.`, + }, + ], + { status: 500 } + ); + } } diff --git a/src/app/dashboard/asistente/page.tsx b/src/app/dashboard/asistente/page.tsx index a23f358..c70378a 100644 --- a/src/app/dashboard/asistente/page.tsx +++ b/src/app/dashboard/asistente/page.tsx @@ -106,7 +106,7 @@ export default function AsistentePage() { const [messages, setMessages] = useState>([ { role: 'assistant', - content: '¡Hola! Soy tu Asistente Financiero y de Crecimiento de 4Ventures. Consulta cifras exactas de inversión, CAC, ROAS o cumplimiento de objetivos. Toda respuesta se calcula mediante herramientas matemáticas cerradas sin alucinación.' + content: '¡Hola! Soy tu **Asistente Financiero y de Crecimiento de 4Ventures** potenciado por Claude. Tengo acceso en tiempo real a toda la información de tu cuenta: finanzas, metas del mes, analítica publicitaria, embudo de ventas, plataforma de marca y calendario editorial. ¿Qué deseas consultar hoy?' } ]); const [loading, setLoading] = useState(false); @@ -172,8 +172,8 @@ export default function AsistentePage() {
-

Asistente IA Financiero & KPIs

-

Temperatura 0.0 · Verificación Numérica Estricta · Supabase RPC

+

Asistente IA Financiero & Growth

+

Motor Claude 4.5 Sonnet · Acceso Integral a Datos del Cliente (Finanzas, KPIs, Marca y Contenidos)

@@ -189,7 +189,7 @@ export default function AsistentePage() { className={`max-w-2xl px-5 py-3.5 rounded-2xl ${ m.role === 'user' ? 'bg-gradient-to-r from-brand-500 to-amber-600 text-white font-medium shadow-md shadow-brand-500/10' - : 'bg-[#1a2333] text-gray-800 border border-gray-200 shadow-lg shadow-black/20' + : 'bg-gray-50 text-gray-900 border border-gray-200/90 shadow-sm' }`} > @@ -199,9 +199,9 @@ export default function AsistentePage() { {loading && (
-
+
- Calculando métricas sin alucinaciones... + Consultando información y analizando métricas con Claude...
)} @@ -211,46 +211,53 @@ export default function AsistentePage() { {/* Quick Prompts Chips */}
- + +
diff --git a/src/lib/assistant-context.ts b/src/lib/assistant-context.ts new file mode 100644 index 0000000..ddf2e1c --- /dev/null +++ b/src/lib/assistant-context.ts @@ -0,0 +1,271 @@ +import { createClient } from '@supabase/supabase-js'; +import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics'; +import { simulateFromTenantMetrics } from '@/lib/simulate'; + +export interface ClientAssistantContext { + companyName: string; + slug: string; + contextPrompt: string; + baseline: { + historicalCpl: number; + conversionRatePct: number; + avgTicket: number; + sampleSpend: number; + }; +} + +function fmtMoney(num: number | null | undefined): string { + const v = Number(num) || 0; + return v.toLocaleString('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; +} + +function fmtNum(num: number | null | undefined): string { + const v = Number(num) || 0; + return v.toLocaleString('es-ES'); +} + +export async function buildClientAssistantContext( + tenantId: string, + serviceKey: string, + supabaseUrl: string = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es' +): Promise { + const admin = createClient(supabaseUrl, serviceKey); + + // 1. Parallel fetch of all client assets and data tables + const [ + tenantRes, + brandDocRes, + targetsRes, + funnelRes, + dealsRes, + kpiWeeklyRes, + adDailyRes, + calendarDocsRes, + creativesRes, + auditsRes + ] = await Promise.allSettled([ + admin.from('tenants').select('id, name, slug, settings').eq('id', tenantId).maybeSingle(), + admin.from('documents').select('content, updated_at').eq('tenant_id', tenantId).eq('type', 'brandbook').order('updated_at', { ascending: false }).limit(1).maybeSingle(), + admin.from('targets').select('*').eq('tenant_id', tenantId).order('month', { ascending: false }).limit(6), + admin.rpc('lead_funnel', { p_tenant_id: tenantId, p_days: 90 }), + admin.from('deals').select('name, amount, stage, status, source, created_at, won_at').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(25), + admin.from('kpi_weekly').select('*').eq('tenant_id', tenantId).order('week', { ascending: false }).limit(12), + admin.from('ad_daily').select('date, platform, campaign_name, spend, impressions, clicks, platform_leads, leads, conversions').eq('tenant_id', tenantId).order('date', { ascending: false }).limit(120), + admin.from('documents').select('id, title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'calendar').order('updated_at', { ascending: false }).limit(4), + admin.from('creatives').select('headline, hook, angle, format, status, campaign_name, primary_text, cta').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(20), + admin.from('documents').select('title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'audit').limit(3) + ]); + + const tenant = tenantRes.status === 'fulfilled' ? tenantRes.value.data : null; + const companyName = tenant?.name || 'Cliente'; + const slug = tenant?.slug || ''; + + // 2. Parse Brandbook / Plataforma de marca + let brand: Record = {}; + if (brandDocRes.status === 'fulfilled' && brandDocRes.value.data?.content) { + let c = brandDocRes.value.data.content; + if (typeof c === 'string') { + try { c = JSON.parse(c); } catch { c = {}; } + } + brand = (c && typeof c === 'object') ? c : {}; + } + + // 3. Targets & Month Objectives + const targets = targetsRes.status === 'fulfilled' ? (targetsRes.value.data || []) : []; + + // 4. Commercial Funnel (90 days) & Deals + const funnel = funnelRes.status === 'fulfilled' ? (funnelRes.value.data || {}) : {}; + const deals = dealsRes.status === 'fulfilled' ? (dealsRes.value.data || []) : []; + + // 5. KPIs & Weekly Cohorts + const kpis: KpiWeeklyRow[] = kpiWeeklyRes.status === 'fulfilled' ? (kpiWeeklyRes.value.data || []) : []; + + // 6. Advertising Daily + const adDaily: AdDailyRow[] = adDailyRes.status === 'fulfilled' ? (adDailyRes.value.data || []) : []; + + // 7. Calendar + const calendarDocs = calendarDocsRes.status === 'fulfilled' ? (calendarDocsRes.value.data || []) : []; + const calendarPieces: Array<{ date?: string; platform?: string; format?: string; title?: string; pillar?: string; status?: string }> = []; + for (const doc of calendarDocs) { + let c = doc.content; + if (typeof c === 'string') { + try { c = JSON.parse(c); } catch { c = null; } + } + if (c && typeof c === 'object' && Array.isArray((c as any).pieces)) { + for (const p of (c as any).pieces) { + if (p && p.title) { + calendarPieces.push({ + date: p.date, + platform: p.platform || (p as any).red, + format: p.format, + title: p.title, + pillar: p.pillar || (p as any).pilar, + status: p.status || p.estado || 'planificado' + }); + } + } + } + } + + // 8. Creatives + const creatives = creativesRes.status === 'fulfilled' ? (creativesRes.value.data || []) : []; + + // 9. Simulation baseline calculation + const sim = simulateFromTenantMetrics({ + spend: 1000, + channel: 'blended', + daily: adDaily, + weekly: kpis + }); + const baseline = { + historicalCpl: Number(sim.baseline?.historical_cpl || 0), + conversionRatePct: Number(sim.baseline?.conversion_rate_pct || 0), + avgTicket: Number(sim.baseline?.avg_ticket || 0), + sampleSpend: Number(sim.baseline?.sample_spend || 0) + }; + + // 10. Aggregated Ads by platform + const spendByPlatform: Record = {}; + const campaignsMap: Record = {}; + for (const row of adDaily) { + const plat = (row.platform || 'meta').toLowerCase(); + if (!spendByPlatform[plat]) spendByPlatform[plat] = { spend: 0, clicks: 0, impressions: 0, leads: 0 }; + const sp = Number(row.spend) || 0; + const cl = Number(row.clicks) || 0; + const im = Number(row.impressions) || 0; + const ld = Number(row.platform_leads || row.conversions) || 0; + + spendByPlatform[plat].spend += sp; + spendByPlatform[plat].clicks += cl; + spendByPlatform[plat].impressions += im; + spendByPlatform[plat].leads += ld; + + const camp = row.campaign_name || 'Campaña General'; + if (!campaignsMap[camp]) campaignsMap[camp] = { platform: plat, spend: 0, clicks: 0, leads: 0 }; + campaignsMap[camp].spend += sp; + campaignsMap[camp].clicks += cl; + campaignsMap[camp].leads += ld; + } + + const topCampaigns = Object.entries(campaignsMap) + .sort((a, b) => b[1].spend - a[1].spend) + .slice(0, 8); + + // 11. Format Sections into structured text for Claude + const brandSection = ` +=== 🏛️ PLATAFORMA DE MARCA & ESTRATEGIA (BRANDBOOK) === +- Marca: ${brand.brand_name || companyName} +- Sector / Vertical: ${brand.vertical || 'Servicios / Comercio'} +- Tagline / Lema: ${brand.tagline || 'No especificado'} +- Elevator Pitch: ${brand.elevator_pitch || 'No especificado'} +- Misión: ${brand.mission || 'No especificada'} +- Visión: ${brand.vision || 'No especificada'} +- Propuesta de Valor: ${brand.value_proposition || 'No especificada'} +- Ventajas Competitivas / USPs: ${Array.isArray(brand.usp_differentiators) ? brand.usp_differentiators.join(' | ') : brand.usp_differentiators || brand.differentiators || 'No especificados'} +- Arquetipo y Personalidad: ${brand.archetype || ''} ${brand.personality ? `· ${brand.personality}` : ''} +- Tono de Voz: ${brand.tone || 'Profesional, cercano y enfocado en resultados'} +- Do's de Voz (Qué hacer): ${Array.isArray(brand.voice_do) ? brand.voice_do.join('; ') : brand.voice_do || 'No especificados'} +- Don'ts de Voz (Qué evitar): ${Array.isArray(brand.voice_dont) ? brand.voice_dont.join('; ') : brand.voice_dont || 'No especificados'} +- Audiencias / ICP: + * Perfil ICP: ${brand.icp || 'Público objetivo definido en el plan'} + * Personas B2C/B2B: ${Array.isArray(brand.personas) ? brand.personas.map((p: any) => typeof p === 'string' ? p : `${p.name || ''} (${p.role || ''}): dolor: ${p.pain || ''}`).join(' | ') : brand.personas || ''} + * Dolores Principales (Pain points): ${brand.barriers || brand.buying_triggers || 'No especificados'} +- Oferta y Servicios: ${brand.core_offer || brand.products_services || 'Cursos y programas'} +- Precios y Condiciones: ${brand.pricing_notes || 'Consultar tarifas comerciales'} +- Prueba Social / Proof Points: ${brand.proof_points || 'Alumnos certificados, casos de éxito reales'} +- Pilares de Contenido RRSS: ${Array.isArray(brand.pillars_rrss) ? brand.pillars_rrss.join(', ') : brand.pillars_rrss || ''} +- Anuncios y Copywriting: + * Winning Hooks: ${Array.isArray(brand.winning_hooks || brand.ads_usps) ? (brand.winning_hooks || brand.ads_usps).join(' | ') : ''} + * Ángulos Creativos: ${Array.isArray(brand.winning_angles || brand.ads_angles) ? (brand.winning_angles || brand.ads_angles).join(' | ') : ''} + * CTAs Principales: ${Array.isArray(brand.allowed_ctas || brand.ads_ctas) ? (brand.allowed_ctas || brand.ads_ctas).join(', ') : ''} +- Reglas Legales y Compliance: ${brand.claims_forbidden || brand.required_disclaimers || 'Sin restricciones extraordinarias'}`; + + const targetsSection = ` +=== 🎯 OBJETIVOS MENSUALES & TARGETS === +${targets.length > 0 + ? targets.map((t: any) => + `- Mes ${t.month}:\n` + + ` * Facturación Objetivo: ${fmtMoney(t.revenue_target)}\n` + + ` * Leads Objetivo: ${fmtNum(t.leads_target)}\n` + + ` * Clientes/Deals Objetivo: ${fmtNum(t.clients_target)}\n` + + ` * Presupuesto Ads Objetivo: ${fmtMoney(t.spend_target)}` + ).join('\n') + : '- No hay metas manuales introducidas aún en la tabla de targets.'}`; + + const funnelSection = ` +=== 🔄 EMBUDO COMERCIAL & VENTAS (Últimos 90 días) === +- Leads Inbound Totales: ${fmtNum(funnel.total_leads)} +- Leads Contactados: ${fmtNum(funnel.contacted_leads)} +- Oportunidades en Pipeline: ${fmtNum(funnel.deals_in_pipeline)} +- Ventas Cerradas Ganadas (Deals Won): ${fmtNum(funnel.deals_won)} +- Facturación Total Ganada: ${fmtMoney(funnel.revenue_won)} +- Ticket Medio por Cliente Ganado: ${fmtMoney(funnel.avg_ticket)} +- Tasa de Conversión (Lead ➔ Cliente): ${Number(funnel.conversion_lead_to_deal_pct || 0).toFixed(2)} % +${deals.length > 0 ? `\nÚltimos acuerdos comerciales registrados:\n` + deals.slice(0, 8).map((d: any) => ` * ${d.name || 'Venta'}: ${fmtMoney(d.amount)} (${d.stage || d.status || 'won'} · ${String(d.won_at || d.created_at || '').slice(0, 10)})`).join('\n') : ''}`; + + const adsSection = ` +=== 📊 PUBLICIDAD & CAMPAÑAS (Últimos 60 días acumulados) === +- Inversión por Plataforma: +${Object.entries(spendByPlatform).map(([plat, dat]) => + ` * ${plat.toUpperCase()}: Inversión ${fmtMoney(dat.spend)} | Leads: ${fmtNum(dat.leads)} | Clics: ${fmtNum(dat.clicks)} | CTR: ${dat.impressions > 0 ? ((dat.clicks / dat.impressions) * 100).toFixed(2) : 0}% | CPL: ${dat.leads > 0 ? fmtMoney(dat.spend / dat.leads) : 'N/A'}` +).join('\n')} + +- Top Campañas por Inversión: +${topCampaigns.length > 0 + ? topCampaigns.map(([camp, dat], i) => ` ${i + 1}. "${camp}" [${dat.platform.toUpperCase()}]: Inversión ${fmtMoney(dat.spend)} · ${fmtNum(dat.leads)} leads · ${fmtNum(dat.clicks)} clics (CPL aprox: ${dat.leads > 0 ? fmtMoney(dat.spend / dat.leads) : 'N/A'})`).join('\n') + : ' * Sin histórico reciente de campañas.'} + +- Baseline Real para Simulaciones: + * CPL Histórico Blended: ${fmtMoney(baseline.historicalCpl)} + * Tasa de Conversión Lead ➔ Venta: ${baseline.conversionRatePct.toFixed(2)} % + * Ticket Medio Histórico: ${fmtMoney(baseline.avgTicket)} + * Base de Muestra Invertida: ${fmtMoney(baseline.sampleSpend)}`; + + const kpisSection = ` +=== 📈 COHORTES Y EFICIENCIA DE CAPITAL (kpi_weekly) === +${kpis.length > 0 + ? kpis.slice(0, 6).map((k: any) => + `- Semana ${k.week} [Canal: ${(k.channel || 'Blended').toUpperCase()}]:\n` + + ` * Inversión: ${fmtMoney(k.total_spend)} | Leads: ${fmtNum(k.total_leads)}\n` + + ` * Clientes Ganados Cohorte: ${fmtNum(k.cohort_deals_won)} | Facturación Cohorte: ${fmtMoney(k.cohort_revenue_won)}\n` + + ` * CAC Cohorte: ${fmtMoney(k.cac_cohort)} | ROAS Cohorte: ${Number(k.roas_cohort || 0).toFixed(2)}x\n` + + ` * CAC Caja: ${fmtMoney(k.cac_cash)} | ROAS Caja: ${Number(k.roas_cash || 0).toFixed(2)}x` + ).join('\n') + : '- Sin métricas semanales agregadas.'}`; + + const calendarSection = ` +=== 📅 CALENDARIO EDITORIAL & PUBLICACIONES === +${calendarPieces.length > 0 + ? calendarPieces.slice(0, 15).map((p, i) => + ` ${i + 1}. [${p.date || 'Sin fecha'}] [${(p.platform || 'RRSS').toUpperCase()}] [${p.format || 'Post'}]: "${p.title}" (Pilar: ${p.pillar || 'General'} · Estado: ${p.status})` + ).join('\n') + : '- No hay publicaciones cargadas en el calendario editorial para este periodo.'}`; + + const creativesSection = ` +=== 🎨 CREATIVIDADES & PIEZAS EN PIPELINE === +${creatives.length > 0 + ? creatives.slice(0, 10).map((c: any, i: number) => + ` ${i + 1}. [${(c.format || 'Ad').toUpperCase()}] Estado: ${c.status || 'borrador'} | Campaña: ${c.campaign_name || 'General'}\n` + + ` - Titular / Headline: "${c.headline || 'Sin título'}"\n` + + ` - Hook / Gancho: "${c.hook || 'Sin hook'}"\n` + + ` - Ángulo: ${c.angle || 'General'} | CTA: ${c.cta || 'Contactar'}` + ).join('\n') + : '- Sin piezas creativas en pipeline.'}`; + + const contextPrompt = `DATOS COMPLETOS DEL CLIENTE EN TIEMPO REAL: +${brandSection} +${targetsSection} +${funnelSection} +${adsSection} +${kpisSection} +${calendarSection} +${creativesSection} +`; + + return { + companyName, + slug, + contextPrompt, + baseline + }; +}