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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { buildClientAssistantContext } from '@/lib/assistant-context';
|
||||||
import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics';
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
import {
|
|
||||||
SimChannel,
|
export const dynamic = 'force-dynamic';
|
||||||
simulateFromTenantMetrics,
|
|
||||||
} from '@/lib/simulate';
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
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 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 supabase = await createClient();
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Contexto de tenant activo
|
||||||
const ctx = await resolveActiveTenant(user);
|
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 tenantId = ctx.tenantId;
|
||||||
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
|
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
|
||||||
if (!tenantId && !ctx.portfolioWide) {
|
|
||||||
return NextResponse.json(
|
// 3. Extraer consulta y mensajes del historial
|
||||||
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
|
|
||||||
{ status: 403 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 1. Extraer mensaje del usuario
|
|
||||||
let userMessage = '';
|
let userMessage = '';
|
||||||
|
let incomingMessages: Array<{ role: string; content: string }> = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
if (typeof body.message === 'string' && body.message.trim()) {
|
if (typeof body.message === 'string' && body.message.trim()) {
|
||||||
userMessage = body.message.trim();
|
userMessage = body.message.trim();
|
||||||
} else if (typeof body.prompt === 'string' && body.prompt.trim()) {
|
} else if (typeof body.prompt === 'string' && body.prompt.trim()) {
|
||||||
userMessage = 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 || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing chat body:', e);
|
console.error('Error parsing chat request body:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normQuery = userMessage
|
if (!userMessage && incomingMessages.length > 0) {
|
||||||
.toLowerCase()
|
const lastUser = [...incomingMessages].reverse().find((m) => m.role === 'user');
|
||||||
.normalize('NFD')
|
if (lastUser) userMessage = lastUser.content;
|
||||||
.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
|
if (!userMessage.trim()) {
|
||||||
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
return NextResponse.json([
|
||||||
const headers = {
|
{
|
||||||
apikey: keyForRpc,
|
text: 'Por favor, escribe una pregunta sobre las finanzas, objetivos, campañas o estrategia del cliente.',
|
||||||
Authorization: `Bearer ${keyForRpc}`,
|
},
|
||||||
'Content-Type': 'application/json'
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper para devolver respuesta uniforme
|
// 6. Preparar System Prompt para Claude
|
||||||
const reply = (text: string) => {
|
const systemPrompt = `Eres el Asistente IA Financiero y de Crecimiento de 4Ventures para el cliente "${companyName}".
|
||||||
return NextResponse.json([{ text }]);
|
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.
|
||||||
|
|
||||||
// 3. Intención: Saludos, Capacidades y "¿Qué puedo preguntar?"
|
${clientDataText}
|
||||||
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) {
|
REGLAS Y PAUTAS INMUTABLES:
|
||||||
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.'
|
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.
|
||||||
return reply(
|
- 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.
|
||||||
`👋 **¡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) {
|
2. FINANZAS Y CÁLCULOS MATEMÁTICOS:
|
||||||
return reply(
|
- CAC de Cohorte: Inversión en Ads ÷ Clientes ganados de ese primer clic (métrica real de rentabilidad).
|
||||||
'Selecciona un **cliente en Contexto** para consultar sus datos (calendario, redes, KPIs). En vista global no mezclo información entre cuentas.'
|
- 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.
|
||||||
|
|
||||||
// 4. Intención: Simulación Determinista "¿Qué pasa si...?"
|
3. SIMULACIONES PREDICTIVAS ("¿Qué pasa si invierto X € en Meta/Google?"):
|
||||||
if (
|
- Si el usuario pide una simulación o proyección con un presupuesto, utiliza el baseline real del cliente:
|
||||||
normQuery.includes('simul') ||
|
* CPL Histórico: ${baseline.historicalCpl > 0 ? `${baseline.historicalCpl.toFixed(2)} €` : 'según datos'}
|
||||||
normQuery.includes('invierto') ||
|
* Tasa de Cierre: ${baseline.conversionRatePct > 0 ? `${baseline.conversionRatePct.toFixed(1)} %` : 'según datos'}
|
||||||
normQuery.includes('pasa si') ||
|
* Ticket Medio: ${baseline.avgTicket > 0 ? `${baseline.avgTicket.toFixed(2)} €` : 'según datos'}
|
||||||
normQuery.includes('proyec') ||
|
- Presenta siempre los 3 escenarios deterministas:
|
||||||
normQuery.includes('presupuesto')
|
a) Escenario Esperado (p50): promedios históricos.
|
||||||
) {
|
b) Escenario Conservador (p10): menor eficiencia (~20-25% más de CPL, menor tasa de cierre).
|
||||||
try {
|
c) Escenario Optimista (p90): rendimiento de mejores campañas (~15-20% menor CPL, mayor cierre).
|
||||||
if (ctx.portfolioWide || !tenantId) {
|
- Concluye con el número proyectado de leads, nuevos clientes, facturación esperada y ROAS resultante.
|
||||||
return reply(
|
|
||||||
'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
|
4. PLATAFORMA DE MARCA Y CONTENIDOS:
|
||||||
let amount = 1000;
|
- Conoce a fondo la propuesta de valor, los arquetipos, la voz (Do's y Don'ts), el ICP y las objeciones del cliente.
|
||||||
if (matchNumber) {
|
- 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.
|
||||||
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
let channel: SimChannel = 'blended';
|
5. FORMATO DE RESPUESTA:
|
||||||
if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta';
|
- Estilo analítico, ejecutivo, directo y proactivo.
|
||||||
else if (normQuery.includes('google')) channel = 'google';
|
- 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.`;
|
||||||
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[] = [];
|
// 7. Sanitizar y estructurar historial de mensajes para la API de Claude
|
||||||
let weekly: KpiWeeklyRow[] = [];
|
const anthropicMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||||
if (agency && ctx.viaView) {
|
|
||||||
const tid = encodeURIComponent(tenantId);
|
// Agregar mensajes previos del chat asegurando alternancia
|
||||||
const [d, w] = await Promise.all([
|
for (const m of incomingMessages) {
|
||||||
restGet<AdDailyRow[]>(
|
const role = m.role === 'user' ? 'user' : 'assistant';
|
||||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
const last = anthropicMessages[anthropicMessages.length - 1];
|
||||||
),
|
if (last && last.role === role) {
|
||||||
restGet<KpiWeeklyRow[]>(
|
last.content += `\n\n${m.content}`;
|
||||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
daily = d.data || [];
|
|
||||||
weekly = w.data || [];
|
|
||||||
} else {
|
} else {
|
||||||
const [{ data: d }, { data: w }] = await Promise.all([
|
anthropicMessages.push({ role, content: m.content });
|
||||||
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
|
// Asegurar que comience con un mensaje 'user'
|
||||||
if (
|
if (anthropicMessages.length === 0 || anthropicMessages[0].role !== 'user') {
|
||||||
normQuery.includes('objetivo') ||
|
anthropicMessages.unshift({ role: 'user', content: userMessage });
|
||||||
normQuery.includes('meta') ||
|
}
|
||||||
normQuery.includes('factura') ||
|
|
||||||
normQuery.includes('target') ||
|
// Si el último mensaje del historial no es el actual, añadirlo
|
||||||
normQuery.includes('cumplimiento')
|
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 {
|
try {
|
||||||
const now = new Date();
|
const client = new Anthropic({ apiKey: anthropicKey });
|
||||||
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) {
|
let response;
|
||||||
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 {
|
try {
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/lead_funnel`, {
|
response = await client.messages.create({
|
||||||
method: 'POST',
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
headers,
|
max_tokens: 3000,
|
||||||
body: JSON.stringify({ p_tenant_id: tenantId, p_days: 30 })
|
temperature: 0.2,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: anthropicMessages,
|
||||||
});
|
});
|
||||||
|
} catch (sonnetErr: any) {
|
||||||
if (res.ok) {
|
console.warn('Error en claude-sonnet-4-5, probando fallback a claude-haiku-4-5:', sonnetErr?.message);
|
||||||
const f = await res.json();
|
response = await client.messages.create({
|
||||||
const text =
|
model: 'claude-haiku-4-5-20251001',
|
||||||
`🔄 **Embudo de Conversión Comercial (Últimos 30 días):**\n\n` +
|
max_tokens: 2500,
|
||||||
`1. **Leads Inbound Captados:** ${f.total_leads || 0}\n` +
|
temperature: 0.2,
|
||||||
`2. **Leads Contactados:** ${f.contacted_leads || 0}\n` +
|
system: systemPrompt,
|
||||||
`3. **Oportunidades en Pipeline:** ${f.deals_in_pipeline || 0}\n` +
|
messages: anthropicMessages,
|
||||||
`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)
|
const replyText = response.content
|
||||||
if (
|
.map((b) => (b.type === 'text' ? b.text : ''))
|
||||||
normQuery.includes('document') ||
|
.join('');
|
||||||
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) {
|
return NextResponse.json([{ text: replyText }]);
|
||||||
const matches = await res.json();
|
} catch (apiErr: any) {
|
||||||
if (Array.isArray(matches) && matches.length > 0) {
|
console.error('Error al consultar la API de Anthropic Claude:', apiErr);
|
||||||
const excerpts = matches.map((m: any) =>
|
return NextResponse.json(
|
||||||
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
[
|
||||||
).join('\n\n');
|
{
|
||||||
|
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.`,
|
||||||
return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`);
|
},
|
||||||
}
|
],
|
||||||
}
|
{ status: 500 }
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function AsistentePage() {
|
|||||||
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([
|
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([
|
||||||
{
|
{
|
||||||
role: 'assistant',
|
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);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -172,8 +172,8 @@ export default function AsistentePage() {
|
|||||||
<Bot className="w-5 h-5" />
|
<Bot className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & KPIs</h3>
|
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & Growth</h3>
|
||||||
<p className="text-[11px] text-gray-400">Temperatura 0.0 · Verificación Numérica Estricta · Supabase RPC</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +189,7 @@ export default function AsistentePage() {
|
|||||||
className={`max-w-2xl px-5 py-3.5 rounded-2xl ${
|
className={`max-w-2xl px-5 py-3.5 rounded-2xl ${
|
||||||
m.role === 'user'
|
m.role === 'user'
|
||||||
? 'bg-gradient-to-r from-brand-500 to-amber-600 text-white font-medium shadow-md shadow-brand-500/10'
|
? '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} />
|
<FormattedMessage content={m.content} />
|
||||||
@@ -199,9 +199,9 @@ export default function AsistentePage() {
|
|||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex justify-start">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -211,46 +211,53 @@ export default function AsistentePage() {
|
|||||||
{/* Quick Prompts Chips */}
|
{/* 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">
|
<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
|
<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"
|
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" />
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
❓ ¿Qué puedo preguntar?
|
❓ ¿Qué puedo preguntar?
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('¿Qué pasa si invierto 1.000 € en Meta?')}
|
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-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
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" />
|
<Target className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
🔮 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" />
|
|
||||||
🎯 Objetivos de este mes
|
🎯 Objetivos de este mes
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('Muéstrame el embudo comercial')}
|
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-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
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
|
🔄 Embudo comercial
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('¿Cuál es el CAC y ROAS por canal de los últimos 30 días?')}
|
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-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
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" />
|
<BookOpen className="w-3.5 h-3.5 text-rose-500" />
|
||||||
📊 CAC & ROAS por canal
|
🏛️ Propuesta de valor & ICP
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('Explícame la metodología 4Ventures y cómo calculáis el CAC de cohorte')}
|
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-700 hover:text-gray-900 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-200/50"
|
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" />
|
<TrendingUp className="w-3.5 h-3.5 text-amber-500" />
|
||||||
📖 Metodología y atribución
|
📅 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>
|
</button>
|
||||||
</div>
|
</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