feat(asistente): integrar Claude 4.5 con contexto integral 360 del cliente (finanzas, kpis, marca, calendario)
This commit is contained in:
@@ -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<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function AsistentePage() {
|
||||
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([
|
||||
{
|
||||
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() {
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & KPIs</h3>
|
||||
<p className="text-[11px] text-gray-400">Temperatura 0.0 · Verificación Numérica Estricta · Supabase RPC</p>
|
||||
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & Growth</h3>
|
||||
<p className="text-[11px] text-gray-500">Motor Claude 4.5 Sonnet · Acceso Integral a Datos del Cliente (Finanzas, KPIs, Marca y Contenidos)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
<FormattedMessage content={m.content} />
|
||||
@@ -199,9 +199,9 @@ export default function AsistentePage() {
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[#1a2333] text-gray-600 px-5 py-3 rounded-2xl text-sm border border-gray-200 flex items-center gap-2">
|
||||
<div className="bg-gray-50 text-gray-700 px-5 py-3 rounded-2xl text-sm border border-gray-200 flex items-center gap-2 shadow-sm">
|
||||
<Sparkles className="w-4 h-4 animate-spin text-brand-500" />
|
||||
Calculando métricas sin alucinaciones...
|
||||
Consultando información y analizando métricas con Claude...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -211,46 +211,53 @@ export default function AsistentePage() {
|
||||
{/* Quick Prompts Chips */}
|
||||
<div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600">
|
||||
<button
|
||||
onClick={() => sendMessage('¿Qué puedo preguntar?')}
|
||||
onClick={() => sendMessage('¿Qué puedo preguntar sobre el cliente?')}
|
||||
className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
❓ ¿Qué puedo preguntar?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('¿Qué pasa si invierto 1.000 € en Meta?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
||||
onClick={() => sendMessage('¿Cómo va el cumplimiento de objetivos y facturación de este mes?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-200 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/70"
|
||||
>
|
||||
<DollarSign className="w-3.5 h-3.5 text-brand-400" />
|
||||
🔮 Simular 1.000 € en Meta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('¿Cómo va el cumplimiento de objetivos de este mes?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
||||
>
|
||||
<Target className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<Target className="w-3.5 h-3.5 text-emerald-600" />
|
||||
🎯 Objetivos de este mes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('Muéstrame el embudo comercial')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
||||
onClick={() => sendMessage('¿Qué pasa si invierto 1.500 € en Meta Ads? Simula los escenarios.')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-200 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/70"
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
||||
<DollarSign className="w-3.5 h-3.5 text-brand-600" />
|
||||
🔮 Simular 1.500 € en Meta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('Analiza el embudo comercial, tasa de conversión y ticket medio')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-200 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/70"
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5 text-blue-600" />
|
||||
🔄 Embudo comercial
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('¿Cuál es el CAC y ROAS por canal de los últimos 30 días?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
||||
onClick={() => sendMessage('¿Cuál es la propuesta de valor y el perfil de cliente ideal (ICP) de la marca?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-200 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/70"
|
||||
>
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
|
||||
📊 CAC & ROAS por canal
|
||||
<BookOpen className="w-3.5 h-3.5 text-rose-500" />
|
||||
🏛️ Propuesta de valor & ICP
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('Explícame la metodología 4Ventures y cómo calculáis el CAC de cohorte')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
||||
onClick={() => sendMessage('¿Cuáles son las próximas publicaciones previstas en el calendario editorial?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-200 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/70"
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 text-amber-400" />
|
||||
📖 Metodología y atribución
|
||||
<TrendingUp className="w-3.5 h-3.5 text-amber-500" />
|
||||
📅 Calendario editorial
|
||||
</button>
|
||||
<button
|
||||
onClick={() => sendMessage('¿Cuál es el CAC y ROAS por canal publicitario?')}
|
||||
className="px-3 py-1.5 rounded-full bg-gray-100 hover:bg-gray-200 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/70"
|
||||
>
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-600" />
|
||||
📊 CAC & ROAS por canal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
271
src/lib/assistant-context.ts
Normal file
271
src/lib/assistant-context.ts
Normal file
@@ -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<ClientAssistantContext> {
|
||||
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<string, any> = {};
|
||||
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<string, { spend: number; clicks: number; impressions: number; leads: number }> = {};
|
||||
const campaignsMap: Record<string, { platform: string; spend: number; clicks: number; leads: number }> = {};
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user