fix(assistant): fix message extraction, dynamic tenant resolution, markdown line breaks and conversational intents
This commit is contained in:
@@ -1,16 +1,43 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
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';
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||||
const tenantId = process.env.DEFAULT_TENANT_ID || '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
|
||||||
|
|
||||||
|
// 1. Extraer mensaje del usuario con compatibilidad para cualquier estructura de body
|
||||||
let userMessage = '';
|
let userMessage = '';
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
userMessage = (body.message || body.prompt || '').toLowerCase();
|
if (typeof body.message === 'string' && body.message.trim()) {
|
||||||
} catch {
|
userMessage = body.message.trim();
|
||||||
// empty body fallback
|
} else if (typeof body.prompt === 'string' && body.prompt.trim()) {
|
||||||
|
userMessage = body.prompt.trim();
|
||||||
|
} else if (Array.isArray(body.messages) && body.messages.length > 0) {
|
||||||
|
const userMsgs = body.messages.filter((m: any) => m.role === 'user');
|
||||||
|
if (userMsgs.length > 0) {
|
||||||
|
userMessage = userMsgs[userMsgs.length - 1].content || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing chat body:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = userMessage.toLowerCase().trim();
|
||||||
|
|
||||||
|
// 2. Obtener tenant_id dinámico del usuario autenticado
|
||||||
|
let tenantId = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541';
|
||||||
|
let companyName = '4Ventures Central';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
tenantId = user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || tenantId;
|
||||||
|
companyName = user.user_metadata?.company || companyName;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error resolving user tenant in chat:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
@@ -19,20 +46,57 @@ export async function POST(req: NextRequest) {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
};
|
};
|
||||||
|
|
||||||
// 0. Intención: Simulación Determinista "¿Qué pasa si...?"
|
// Helper para devolver respuesta uniforme
|
||||||
if (userMessage.includes('simul') || userMessage.includes('invierto') || userMessage.includes('pasa si') || userMessage.includes('proyec')) {
|
const reply = (text: string) => {
|
||||||
|
return NextResponse.json([{ text }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Intención: Saludos y Presentación
|
||||||
|
if (
|
||||||
|
query === '' ||
|
||||||
|
query === 'hola' ||
|
||||||
|
query === 'buenas' ||
|
||||||
|
query === 'buenos dias' ||
|
||||||
|
query === 'buenas tardes' ||
|
||||||
|
query === 'hey' ||
|
||||||
|
query.includes('que puedes hacer') ||
|
||||||
|
query.includes('quien eres') ||
|
||||||
|
query.includes('ayuda')
|
||||||
|
) {
|
||||||
|
return reply(
|
||||||
|
`👋 **¡Hola! Soy el Asistente Financiero y 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):\n\n` +
|
||||||
|
`💡 **¿Qué puedes consultarme?**\n` +
|
||||||
|
`1. **Simulaciones presupuestarias:** *«¿Qué pasa si invierto 1.500 € en Meta?»* o *«Simular 2.000 € en Google»*.\n` +
|
||||||
|
`2. **Cumplimiento de Objetivos:** *«¿Cómo vamos con los objetivos de este mes?»*.\n` +
|
||||||
|
`3. **Embudo y Conversión:** *«Muéstrame el embudo comercial y tasa de cierre»*.\n` +
|
||||||
|
`4. **Métricas de CAC y ROAS:** *«¿Cuál es el CAC y ROAS de los últimos 30 días?»*.\n` +
|
||||||
|
`5. **Ranking de Anuncios:** *«¿Cuáles son las mejores campañas activas?»*.\n` +
|
||||||
|
`6. **Biblioteca de Activos:** *«Busca copys de Meta Ads o informes de auditoría»*.\n\n` +
|
||||||
|
`¿Qué métrica o escenario te gustaría analizar hoy?`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Intención: Simulación Determinista "¿Qué pasa si...?"
|
||||||
|
if (
|
||||||
|
query.includes('simul') ||
|
||||||
|
query.includes('invierto') ||
|
||||||
|
query.includes('pasa si') ||
|
||||||
|
query.includes('proyec') ||
|
||||||
|
query.includes('presupuesto')
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Extraer importe numérico
|
const matchNumber = query.match(/(\d+[\d\.,]*)/);
|
||||||
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
|
|
||||||
let amount = 1000;
|
let amount = 1000;
|
||||||
if (matchNumber) {
|
if (matchNumber) {
|
||||||
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel: string | null = null;
|
let channel: string | null = null;
|
||||||
if (userMessage.includes('meta') || userMessage.includes('facebook') || userMessage.includes('instagram')) channel = 'meta';
|
if (query.includes('meta') || query.includes('facebook') || query.includes('instagram')) channel = 'meta';
|
||||||
else if (userMessage.includes('google')) channel = 'google';
|
else if (query.includes('google')) channel = 'google';
|
||||||
else if (userMessage.includes('tiktok')) channel = 'tiktok';
|
else if (query.includes('tiktok')) channel = 'tiktok';
|
||||||
|
else if (query.includes('linkedin')) channel = 'linkedin';
|
||||||
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -51,21 +115,143 @@ export async function POST(req: NextRequest) {
|
|||||||
const p90 = sim.optimistic_p90 || {};
|
const p90 = sim.optimistic_p90 || {};
|
||||||
const base = sim.baseline || {};
|
const base = sim.baseline || {};
|
||||||
|
|
||||||
const text = `🔮 **Simulación de Inversión Adicional (${amount.toLocaleString('es-ES')} € en ${sim.target_channel.toUpperCase()}):**\n\n` +
|
const channelLabel = (sim.target_channel || 'General').toUpperCase();
|
||||||
`*Métricas Base Reales:* CPL ${base.historical_cpl} €, Tasa de Cierre ${base.conversion_rate_pct}%, Ticket Medio ${Number(base.avg_ticket || 0).toLocaleString('es-ES')} €.\n\n` +
|
const text =
|
||||||
`- **Escenario Esperado (p50):** +${p50.leads} leads, +${p50.deals} clientes, **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p50.roas}x)\n` +
|
`🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` +
|
||||||
`- **Escenario Conservador (p10):** +${p10.leads} leads, +${p10.deals} clientes, **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p10.roas}x)\n` +
|
`📈 **Métricas Base Observadas:**\n` +
|
||||||
`- **Escenario Optimista (p90):** +${p90.leads} leads, +${p90.deals} clientes, **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS ${p90.roas}x)\n\n` +
|
`- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
||||||
`*Proyección calculada matemáticamente sin alucinaciones con la RPC simulate_budget.*`;
|
`- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
|
||||||
return Response.json([{ text }]);
|
`- **Ticket Medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n\n` +
|
||||||
|
`📊 **Proyección por Escenarios Matemáticos:**\n` +
|
||||||
|
`- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` +
|
||||||
|
`- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` +
|
||||||
|
`- **Escenario Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p90.roas}x**)\n\n` +
|
||||||
|
`💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`;
|
||||||
|
|
||||||
|
return reply(text);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error in simulate_budget chat:', e);
|
console.error('Error in simulate_budget chat:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0.5 Intención: RAG Documental Híbrido (Documentos / Copys / Informes)
|
// 5. Intención: Objetivos vs Real / Estrategia
|
||||||
if (userMessage.includes('document') || userMessage.includes('copy') || userMessage.includes('informe') || userMessage.includes('brief') || userMessage.includes('contenido') || userMessage.includes('activo')) {
|
if (
|
||||||
|
query.includes('objetivo') ||
|
||||||
|
query.includes('meta') ||
|
||||||
|
query.includes('factura') ||
|
||||||
|
query.includes('target') ||
|
||||||
|
query.includes('cumplimiento')
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ p_month: currentMonth })
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
query.includes('funnel') ||
|
||||||
|
query.includes('embudo') ||
|
||||||
|
query.includes('tasa') ||
|
||||||
|
query.includes('ticket') ||
|
||||||
|
query.includes('conversion') ||
|
||||||
|
query.includes('cierre')
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/lead_funnel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ p_tenant_id: tenantId, p_days: 30 })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const f = await res.json();
|
||||||
|
const text =
|
||||||
|
`🔄 **Embudo de Conversión Comercial (Últimos 30 días):**\n\n` +
|
||||||
|
`1. **Leads Inbound Captados:** ${f.total_leads || 0}\n` +
|
||||||
|
`2. **Leads Contactados:** ${f.contacted_leads || 0}\n` +
|
||||||
|
`3. **Oportunidades en Pipeline:** ${f.deals_in_pipeline || 0}\n` +
|
||||||
|
`4. **Ventas Cerradas Ganadas:** **${f.deals_won || 0}**\n\n` +
|
||||||
|
`💰 **Resultados Financieros:**\n` +
|
||||||
|
`- **Facturación Cerrada:** **${Number(f.revenue_won || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €**\n` +
|
||||||
|
`- **Ticket Medio:** **${Number(f.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €**\n` +
|
||||||
|
`- **Tasa de Cierre Lead ➔ Venta:** **${f.conversion_lead_to_deal_pct || 0}%**\n\n` +
|
||||||
|
`*Fuente: Webhook deal-won n8n y tabla deals en PostgreSQL.*`;
|
||||||
|
|
||||||
|
return reply(text);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error in lead_funnel:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Intención: Ranking de Campañas / Anuncios
|
||||||
|
if (
|
||||||
|
query.includes('campa') ||
|
||||||
|
query.includes('anuncio') ||
|
||||||
|
query.includes('ranking') ||
|
||||||
|
query.includes('mejor') ||
|
||||||
|
query.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: Datos diarios procesados vía Windsor.ai.*`);
|
||||||
|
} 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 (
|
||||||
|
query.includes('document') ||
|
||||||
|
query.includes('copy') ||
|
||||||
|
query.includes('informe') ||
|
||||||
|
query.includes('brief') ||
|
||||||
|
query.includes('activo') ||
|
||||||
|
query.includes('creativo')
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/match_documents`, {
|
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/match_documents`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -76,6 +262,7 @@ export async function POST(req: NextRequest) {
|
|||||||
p_match_count: 3
|
p_match_count: 3
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const matches = await res.json();
|
const matches = await res.json();
|
||||||
if (Array.isArray(matches) && matches.length > 0) {
|
if (Array.isArray(matches) && matches.length > 0) {
|
||||||
@@ -83,8 +270,17 @@ export async function POST(req: NextRequest) {
|
|||||||
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
||||||
).join('\n\n');
|
).join('\n\n');
|
||||||
|
|
||||||
const text = `📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Fuente: Búsqueda vectorial y de texto completo en document_chunks.*`;
|
return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`);
|
||||||
return Response.json([{ text }]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
} catch (e) {
|
||||||
@@ -92,107 +288,61 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Intención: Objetivos vs Real / Estrategia
|
// 9. Intención: Metodología y Glosario 4Ventures
|
||||||
if (userMessage.includes('objetivo') || userMessage.includes('meta') || userMessage.includes('factura') || userMessage.includes('estrategia')) {
|
if (
|
||||||
try {
|
query.includes('que es cac') ||
|
||||||
const now = new Date();
|
query.includes('cohorte') ||
|
||||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
query.includes('caja') ||
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/targets_vs_actual`, {
|
query.includes('atribucion') ||
|
||||||
method: 'POST',
|
query.includes('90 dias') ||
|
||||||
headers,
|
query.includes('metodologia')
|
||||||
body: JSON.stringify({ p_month: currentMonth })
|
) {
|
||||||
});
|
return reply(
|
||||||
if (res.ok) {
|
`📖 **Metodología Oficial de Crecimiento — 4Ventures:**\n\n` +
|
||||||
const data = await res.json();
|
`1. **CAC de Cohorte (Cohort CAC):**\n` +
|
||||||
const t = data[0] || {};
|
` 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` +
|
||||||
const revAct = Number(t.revenue_actual || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 });
|
`2. **CAC de Caja (Cash CAC):**\n` +
|
||||||
const revTar = Number(t.revenue_target || 0).toLocaleString('es-ES');
|
` Inversión total del mes dividida entre los clientes cerrados ese mismo mes. Sirve para controlar el flujo de tesorería mensual.\n\n` +
|
||||||
const text = `🎯 **Estado de Objetivos Mensuales (Mes ${currentMonth}):**\n\n` +
|
`3. **Atribución Universal a 90 Días (` + '`_4v_attr.js`' + `):**\n` +
|
||||||
`- **Facturación:** ${revAct} € de ${revTar} € (${t.revenue_achievement_pct || 0}% de la meta)\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` +
|
||||||
`- **Leads Captados:** ${t.leads_actual || 0} de ${t.leads_target || 0} objetivo\n` +
|
`4. **Cierre de Bucle CRM (` + '`deal-won`' + `):**\n` +
|
||||||
`- **Clientes Ganados:** ${t.clients_actual || 0} de ${t.clients_target || 0} objetivo\n\n` +
|
` Cuando tu equipo de ventas marca un cliente como "Ganado", n8n recupera el origen original y recalcula el ROAS al instante.`
|
||||||
`*Fuente: RPC targets_vs_actual (Supabase PostgreSQL)*`;
|
);
|
||||||
return Response.json([{ text }]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error in targets_vs_actual:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Intención: Embudo / Funnel / Conversión
|
// 10. Consulta de KPIs Semanales / CAC & ROAS (Default Analítico)
|
||||||
if (userMessage.includes('funnel') || userMessage.includes('embudo') || userMessage.includes('tasa') || userMessage.includes('ticket')) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/lead_funnel`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ p_tenant_id: tenantId, p_days: 30 })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const f = await res.json();
|
|
||||||
const text = `🔄 **Embudo de Conversión Comercial (Últimos 30 días):**\n\n` +
|
|
||||||
`- **Leads Registrados:** ${f.total_leads || 0}\n` +
|
|
||||||
`- **Leads Contactados:** ${f.contacted_leads || 0}\n` +
|
|
||||||
`- **Deals en Pipeline:** ${f.deals_in_pipeline || 0}\n` +
|
|
||||||
`- **Deals Ganados:** ${f.deals_won || 0}\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: RPC lead_funnel (Supabase PostgreSQL)*`;
|
|
||||||
return Response.json([{ text }]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error in lead_funnel:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Intención: Ranking de Campañas / Anuncios
|
|
||||||
if (userMessage.includes('campa') || userMessage.includes('anuncio') || userMessage.includes('ranking') || userMessage.includes('mejor')) {
|
|
||||||
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();
|
|
||||||
const list = campaigns.map((c: any) =>
|
|
||||||
`- **${c.campaign_name}** (${c.platform.toUpperCase()}): Inversión ${Number(c.spend || 0).toFixed(2)} €, ${c.clicks || 0} clics, ${c.impressions || 0} imp., ${c.leads || 0} leads.`
|
|
||||||
).join('\n');
|
|
||||||
const text = `🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list || 'No constan campañas publicitarias activas en este periodo.'}\n\n*Fuente: RPC rank_campaigns (Windsor.ai pipeline)*`;
|
|
||||||
return Response.json([{ text }]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error in rank_campaigns:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Default: Consulta de KPIs Semanales (CAC & ROAS)
|
|
||||||
let kpis: any[] = [];
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?select=*`, {
|
const res = await fetch(`${supabaseUrl}/rest/v1/kpi_weekly?tenant_id=eq.${tenantId}&select=*`, { headers });
|
||||||
headers: {
|
|
||||||
apikey: serviceKey,
|
|
||||||
Authorization: `Bearer ${serviceKey}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
kpis = await res.json();
|
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) {
|
} catch (e) {
|
||||||
console.error('Error fetching KPIs for assistant:', e);
|
console.error('Error fetching KPIs for assistant:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kpis.length === 0) {
|
return reply(
|
||||||
return Response.json([{
|
`📊 **Centro de Analítica — ${companyName}:**\n\n` +
|
||||||
text: 'Actualmente no constan datos consolidados en la vista de KPIs para este periodo. Por favor, asegúrate de que el webhook de leads y la sincronización diaria de Windsor.ai hayan registrado datos.'
|
`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` +
|
||||||
const channelsSummary = kpis.map((k: any) =>
|
`- Escribe *«Campañas»* para ver los anuncios con mayor inversión.`
|
||||||
`- Canal **${k.channel.toUpperCase()}**: ${k.total_leads} leads captados, inversión de ${Number(k.total_spend || 0).toFixed(2)} €, ${k.cohort_deals_won} clientes ganados, facturación de ${Number(k.cohort_revenue_won || 0).toFixed(2)} €, CAC: ${k.cac_cohort} €, ROAS: ${k.roas_cohort}x.`
|
);
|
||||||
).join('\n');
|
|
||||||
|
|
||||||
const answer = `📊 **Resumen de Rendimiento Analítico (Fuente: kpi_weekly, corte horario):**\n\n${channelsSummary}\n\n💡 **Regla de Atribución:** Calculado según el modelo oficial de cohorte a 60 días sin alucinación. Puedes preguntarme también sobre *objetivos*, *embudo de conversión* o *mejores campañas*.`;
|
|
||||||
|
|
||||||
return Response.json([{ text: answer }]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Bot, Send, Sparkles } from 'lucide-react';
|
import { Bot, Send, Sparkles, TrendingUp, Target, Filter, DollarSign, BookOpen } from 'lucide-react';
|
||||||
|
|
||||||
export default function AsistentePage() {
|
export default function AsistentePage() {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
@@ -12,6 +12,15 @@ export default function AsistentePage() {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, loading]);
|
||||||
|
|
||||||
const sendMessage = async (textToSend?: string) => {
|
const sendMessage = async (textToSend?: string) => {
|
||||||
const text = textToSend || input;
|
const text = textToSend || input;
|
||||||
@@ -26,12 +35,25 @@ export default function AsistentePage() {
|
|||||||
const res = await fetch('/api/chat', {
|
const res = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ messages: newMessages })
|
body: JSON.stringify({
|
||||||
|
message: text,
|
||||||
|
messages: newMessages
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const assistantText = typeof data === 'string' ? data : (data[0]?.text || JSON.stringify(data));
|
let assistantText = '';
|
||||||
|
if (Array.isArray(data) && data[0]?.text) {
|
||||||
|
assistantText = data[0].text;
|
||||||
|
} else if (data && typeof data.text === 'string') {
|
||||||
|
assistantText = data.text;
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
assistantText = data;
|
||||||
|
} else {
|
||||||
|
assistantText = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
setMessages([...newMessages, { role: 'assistant', content: assistantText }]);
|
setMessages([...newMessages, { role: 'assistant', content: assistantText }]);
|
||||||
} else {
|
} else {
|
||||||
setMessages([...newMessages, { role: 'assistant', content: 'Lo siento, hubo un problema al procesar la consulta con las herramientas de analítica.' }]);
|
setMessages([...newMessages, { role: 'assistant', content: 'Lo siento, hubo un problema al procesar la consulta con las herramientas de analítica.' }]);
|
||||||
@@ -45,6 +67,7 @@ export default function AsistentePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-10rem)] bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
<div className="flex flex-col h-[calc(100vh-10rem)] bg-[#111827] border border-gray-800 rounded-xl overflow-hidden shadow-sm">
|
||||||
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-gray-800 flex items-center justify-between bg-[#0D121F]">
|
<div className="p-4 border-b border-gray-800 flex items-center justify-between bg-[#0D121F]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-brand-500/20 text-brand-500 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-brand-500/20 text-brand-500 flex items-center justify-center">
|
||||||
@@ -52,11 +75,12 @@ export default function AsistentePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-sm text-white">Asistente IA Financiero & KPIs</h3>
|
<h3 className="font-bold text-sm text-white">Asistente IA Financiero & KPIs</h3>
|
||||||
<p className="text-[11px] text-gray-400">Temperatura 0.0 · Verificación Numérica Estricta · Límite: 200 msg/mes</p>
|
<p className="text-[11px] text-gray-400">Temperatura 0.0 · Verificación Numérica Estricta · Supabase RPC</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Feed */}
|
||||||
<div className="flex-1 p-6 overflow-y-auto space-y-4">
|
<div className="flex-1 p-6 overflow-y-auto space-y-4">
|
||||||
{messages.map((m, idx) => (
|
{messages.map((m, idx) => (
|
||||||
<div
|
<div
|
||||||
@@ -64,41 +88,68 @@ export default function AsistentePage() {
|
|||||||
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`max-w-2xl px-4 py-3 rounded-xl text-sm leading-relaxed ${
|
className={`max-w-2xl px-4 py-3 rounded-xl text-sm leading-relaxed whitespace-pre-wrap ${
|
||||||
m.role === 'user'
|
m.role === 'user'
|
||||||
? 'bg-brand-500 text-white font-medium'
|
? 'bg-brand-500 text-white font-medium shadow-md shadow-brand-500/10'
|
||||||
: 'bg-[#1F2937] text-gray-200 border border-gray-700/60'
|
: 'bg-[#1F2937] text-gray-200 border border-gray-700/60 shadow-md shadow-black/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{m.content}
|
{m.content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="bg-[#1F2937] text-gray-400 px-4 py-3 rounded-xl text-sm border border-gray-700/60 flex items-center gap-2">
|
<div className="bg-[#1F2937] text-gray-300 px-4 py-3 rounded-xl text-sm border border-gray-700/60 flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 animate-spin text-brand-500" />
|
<Sparkles className="w-4 h-4 animate-spin text-brand-500" />
|
||||||
Consultando base de datos y calculando métricas...
|
Calculando proyecciones y consultando base de datos...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-2 bg-[#0D121F]/60 border-t border-gray-800/80 flex gap-2 overflow-x-auto text-xs text-gray-300">
|
{/* Quick Prompts Chips */}
|
||||||
|
<div className="px-6 py-2.5 bg-[#0D121F]/80 border-t border-gray-800/80 flex gap-2 overflow-x-auto text-xs text-gray-300">
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('¿Cuál es el CAC y ROAS por canal de los últimos 30 días?')}
|
onClick={() => sendMessage('¿Qué pasa si invierto 1.000 € en Meta?')}
|
||||||
className="px-3 py-1 rounded-full bg-gray-800 hover:bg-gray-700 whitespace-nowrap transition-colors"
|
className="px-3 py-1.5 rounded-full bg-gray-800 hover:bg-gray-700 hover:text-white whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-700/50"
|
||||||
>
|
>
|
||||||
📊 CAC & ROAS últimos 30 días
|
<DollarSign className="w-3.5 h-3.5 text-brand-400" />
|
||||||
|
🔮 Simular 1.000 € en Meta
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage('¿Cómo va el cumplimiento de objetivos de este mes?')}
|
onClick={() => sendMessage('¿Cómo va el cumplimiento de objetivos de este mes?')}
|
||||||
className="px-3 py-1 rounded-full bg-gray-800 hover:bg-gray-700 whitespace-nowrap transition-colors"
|
className="px-3 py-1.5 rounded-full bg-gray-800 hover:bg-gray-700 hover:text-white whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-700/50"
|
||||||
>
|
>
|
||||||
|
<Target className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
🎯 Objetivos de este mes
|
🎯 Objetivos de este mes
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage('Muéstrame el embudo de conversión y tasa de cierre de los últimos 30 días')}
|
||||||
|
className="px-3 py-1.5 rounded-full bg-gray-800 hover:bg-gray-700 hover:text-white whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-700/50"
|
||||||
|
>
|
||||||
|
<Filter className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
🔄 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-800 hover:bg-gray-700 hover:text-white whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-700/50"
|
||||||
|
>
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
📊 CAC & ROAS por canal
|
||||||
|
</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-800 hover:bg-gray-700 hover:text-white whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-gray-700/50"
|
||||||
|
>
|
||||||
|
<BookOpen className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
📖 Metodología y atribución
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Input Box */}
|
||||||
<div className="p-4 border-t border-gray-800 bg-[#0D121F]">
|
<div className="p-4 border-t border-gray-800 bg-[#0D121F]">
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -111,13 +162,13 @@ export default function AsistentePage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Pregunta sobre métricas, CAC, ROAS o cumplimiento de objetivos..."
|
placeholder="Escribe una pregunta sobre inversión, metas, CAC, o escribe «Hola»..."
|
||||||
className="flex-1 bg-[#111827] border border-gray-700 rounded-lg px-4 py-2.5 text-sm text-white focus:outline-none focus:border-brand-500"
|
className="flex-1 bg-[#111827] border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-500 transition-all"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || !input.trim()}
|
||||||
className="bg-brand-500 hover:bg-brand-600 disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg flex items-center gap-2 text-sm transition-colors"
|
className="bg-gradient-to-r from-brand-500 to-amber-600 hover:from-brand-600 hover:to-amber-700 disabled:opacity-50 text-white font-medium px-5 py-2.5 rounded-xl flex items-center gap-2 text-sm transition-all shadow-lg shadow-brand-500/20 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" /> Enviar
|
<Send className="w-4 h-4" /> Enviar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user