feat(asistente): integrar Claude 4.5 con contexto integral 360 del cliente (finanzas, kpis, marca, calendario)

This commit is contained in:
Marcos Fargas
2026-10-18 01:26:00 +02:00
parent 4404b309c8
commit f034522e7b
3 changed files with 506 additions and 436 deletions

View File

@@ -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 || '';
}
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) {
console.error('Error parsing chat body:', e);
console.error('Error parsing chat request body:', e);
}
const normQuery = userMessage
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[¿?¡!.,;:_()]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!userMessage && incomingMessages.length > 0) {
const lastUser = [...incomingMessages].reverse().find((m) => m.role === 'user');
if (lastUser) userMessage = lastUser.content;
}
// 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'
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,
};
// Helper para devolver respuesta uniforme
const reply = (text: string) => {
return NextResponse.json([{ text }]);
};
// 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.
// 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');
${clientDataText}
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.'
);
}
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.*`
);
}
============================================================
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.
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.'
);
}
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.
// 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).'
);
}
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.
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
let amount = 1000;
if (matchNumber) {
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
}
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.
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.'
);
}
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.`;
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 || [];
// 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 {
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);
anthropicMessages.push({ role, content: m.content });
}
}
// 5. Intención: Objetivos vs Real / Estrategia
if (
normQuery.includes('objetivo') ||
normQuery.includes('meta') ||
normQuery.includes('factura') ||
normQuery.includes('target') ||
normQuery.includes('cumplimiento')
) {
// 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 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,
}),
});
const client = new Anthropic({ apiKey: anthropicKey });
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')
) {
let response;
try {
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/lead_funnel`, {
method: 'POST',
headers,
body: JSON.stringify({ p_tenant_id: tenantId, p_days: 30 })
response = await client.messages.create({
model: 'claude-sonnet-4-5-20250929',
max_tokens: 3000,
temperature: 0.2,
system: systemPrompt,
messages: anthropicMessages,
});
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 })
} 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,
});
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
})
});
const replyText = response.content
.map((b) => (b.type === 'text' ? b.text : ''))
.join('');
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.*`
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 }
);
}
}
} 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.`
);
}

View File

@@ -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>

View 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
};
}