diff --git a/src/app/api/chat/feedback/route.ts b/src/app/api/chat/feedback/route.ts new file mode 100644 index 0000000..b6ee3bd --- /dev/null +++ b/src/app/api/chat/feedback/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { createClient as createAdminClient } from '@supabase/supabase-js'; +import { resolveActiveTenant } from '@/lib/tenant-context'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; + + // 1. Autenticación de usuario + const supabase = await createClient(); + 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 tenantId = ctx.tenantId; + + if (!tenantId) { + return NextResponse.json({ error: 'No active tenant selected' }, { status: 400 }); + } + + // 3. Parsear body + let messageId = ''; + let rating: 'good' | 'bad' | null = null; + let notes: string | undefined = undefined; + + try { + const body = await req.json(); + messageId = typeof body.messageId === 'string' ? body.messageId.trim() : ''; + if (body.rating === 'good' || body.rating === 'bad' || body.rating === null) { + rating = body.rating; + } + if (typeof body.notes === 'string') { + notes = body.notes.trim(); + } + } catch (e) { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!messageId) { + return NextResponse.json({ error: 'messageId is required' }, { status: 400 }); + } + + // 4. Actualizar tool_calls con el rating para feedback y aprendizaje + const admin = createAdminClient(supabaseUrl, serviceKey); + + const { data: currentMsg, error: fetchErr } = await admin + .from('chat_messages') + .select('id, tool_calls, tenant_id, role') + .eq('id', messageId) + .eq('tenant_id', tenantId) + .maybeSingle(); + + if (fetchErr) { + console.error('Error fetching message for feedback:', fetchErr); + return NextResponse.json({ error: fetchErr.message }, { status: 500 }); + } + + if (!currentMsg) { + return NextResponse.json({ error: 'Message not found in active tenant' }, { status: 404 }); + } + + const existingToolCalls = (currentMsg.tool_calls && typeof currentMsg.tool_calls === 'object') + ? currentMsg.tool_calls + : {}; + + const updatedToolCalls = { + ...existingToolCalls, + rating, + rated_at: rating ? new Date().toISOString() : null, + rated_by: rating ? user.id : null, + notes: notes !== undefined ? notes : (existingToolCalls as any).notes, + }; + + const { error: updateErr } = await admin + .from('chat_messages') + .update({ tool_calls: updatedToolCalls }) + .eq('id', messageId) + .eq('tenant_id', tenantId); + + if (updateErr) { + console.error('Error updating message feedback:', updateErr); + return NextResponse.json({ error: updateErr.message }, { status: 500 }); + } + + return NextResponse.json({ + ok: true, + messageId, + rating, + updatedAt: updatedToolCalls.rated_at, + }); +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 2687674..621bec9 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,11 +1,90 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { createClient as createAdminClient } from '@supabase/supabase-js'; 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 GET(req: NextRequest) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + const tenantId = ctx.tenantId; + + if (!tenantId) { + return NextResponse.json({ messages: [] }); + } + + const admin = createAdminClient(supabaseUrl, serviceKey); + const { data, error } = await admin + .from('chat_messages') + .select('id, role, content, tool_calls, created_at') + .eq('tenant_id', tenantId) + .order('created_at', { ascending: true }) + .limit(100); + + if (error) { + console.error('Error fetching chat history:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const messages = (data || []).map((m: any) => ({ + id: m.id, + role: m.role, + content: m.content, + rating: m.tool_calls?.rating || null, + createdAt: m.created_at, + })); + + return NextResponse.json({ messages }); +} + +export async function DELETE(req: NextRequest) { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const ctx = await resolveActiveTenant(user); + const tenantId = ctx.tenantId; + + if (!tenantId) { + return NextResponse.json({ ok: true, deleted: 0 }); + } + + const admin = createAdminClient(supabaseUrl, serviceKey); + const { error } = await admin + .from('chat_messages') + .delete() + .eq('tenant_id', tenantId); + + if (error) { + console.error('Error deleting chat history:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true, message: 'Historial eliminado con éxito' }); +} + export async function POST(req: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es'; const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || ''; @@ -99,7 +178,26 @@ Instrucciones: } } - // 5. Caso: Cliente específico activo -> Cargar TODO el contexto del cliente + // 5. Caso: Cliente específico activo -> Persistir pregunta del usuario en el historial + const admin = createAdminClient(supabaseUrl, serviceKey); + let userMessageRowId: string | null = null; + try { + const { data: uRow } = await admin + .from('chat_messages') + .insert({ + tenant_id: tenantId, + user_id: user.id, + role: 'user', + content: userMessage, + }) + .select('id') + .single(); + if (uRow?.id) userMessageRowId = uRow.id; + } catch (dbErr) { + console.error('Error guardando mensaje del usuario en chat_messages:', dbErr); + } + + // 6. Cargar TODO el contexto del cliente (incluyendo histórico y feedback) let clientContext; try { clientContext = await buildClientAssistantContext( @@ -215,7 +313,36 @@ REGLAS Y PAUTAS INMUTABLES: .map((b) => (b.type === 'text' ? b.text : '')) .join(''); - return NextResponse.json([{ text: replyText }]); + let assistantMessageRowId: string | null = null; + try { + const { data: aRow } = await admin + .from('chat_messages') + .insert({ + tenant_id: tenantId, + user_id: user.id, + role: 'assistant', + content: replyText, + tool_calls: { + model: 'claude-sonnet-4-5-20250929', + user_message_id: userMessageRowId, + }, + }) + .select('id') + .single(); + if (aRow?.id) assistantMessageRowId = aRow.id; + } catch (dbErr) { + console.error('Error guardando respuesta del asistente en chat_messages:', dbErr); + } + + return NextResponse.json([ + { + id: assistantMessageRowId, + role: 'assistant', + text: replyText, + content: replyText, + rating: null, + }, + ]); } catch (apiErr: any) { console.error('Error al consultar la API de Anthropic Claude:', apiErr); return NextResponse.json( diff --git a/src/app/dashboard/asistente/page.tsx b/src/app/dashboard/asistente/page.tsx index c70378a..b31003a 100644 --- a/src/app/dashboard/asistente/page.tsx +++ b/src/app/dashboard/asistente/page.tsx @@ -1,7 +1,31 @@ "use client"; import React, { useState, useRef, useEffect } from 'react'; -import { Bot, Send, Sparkles, TrendingUp, Target, Filter, DollarSign, BookOpen, HelpCircle } from 'lucide-react'; +import { + Bot, + Send, + Sparkles, + TrendingUp, + Target, + Filter, + DollarSign, + BookOpen, + HelpCircle, + ThumbsUp, + ThumbsDown, + RotateCcw, + CheckCircle, + Info +} from 'lucide-react'; + +interface ChatMessage { + id?: string; + role: 'user' | 'assistant'; + content: string; + rating?: 'good' | 'bad' | null; + ratingLoading?: boolean; + createdAt?: string; +} function FormattedMessage({ content }: { content: string }) { const lines = content.split('\n'); @@ -101,30 +125,70 @@ function FormattedMessage({ content }: { content: string }) { ); } +const DEFAULT_WELCOME_MESSAGE: ChatMessage = { + role: 'assistant', + 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. Además, **aprendo continuamente de tus valoraciones** (👍 / 👎) para afinar cada respuesta. ¿Qué deseas consultar hoy?' +}; + export default function AsistentePage() { const [input, setInput] = useState(''); - const [messages, setMessages] = useState>([ - { - role: 'assistant', - 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 [messages, setMessages] = useState([DEFAULT_WELCOME_MESSAGE]); const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [clearing, setClearing] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); + const [toastMessage, setToastMessage] = useState(null); const messagesEndRef = useRef(null); + const showToast = (msg: string) => { + setToastMessage(msg); + setTimeout(() => { + setToastMessage((current) => (current === msg ? null : current)); + }, 4000); + }; + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; + // Cargar historial de chat persistente en Supabase useEffect(() => { - scrollToBottom(); - }, [messages, loading]); + async function fetchChatHistory() { + try { + setInitialLoading(true); + const res = await fetch('/api/chat', { cache: 'no-store' }); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data.messages) && data.messages.length > 0) { + setMessages(data.messages); + } else { + setMessages([DEFAULT_WELCOME_MESSAGE]); + } + } + } catch (e) { + console.error('Error al cargar historial de chat:', e); + } finally { + setInitialLoading(false); + } + } + fetchChatHistory(); + }, []); + + useEffect(() => { + if (!initialLoading) { + scrollToBottom(); + } + }, [messages, loading, initialLoading]); + + // Enviar mensaje const sendMessage = async (textToSend?: string) => { const text = textToSend || input; if (!text.trim() || loading) return; - const newMessages = [...messages, { role: 'user', content: text }]; + const userMsg: ChatMessage = { role: 'user', content: text }; + const newMessages = [...messages, userMsg]; setMessages(newMessages); setInput(''); setLoading(true); @@ -135,54 +199,195 @@ export default function AsistentePage() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, - messages: newMessages + messages: newMessages.map((m) => ({ role: m.role, content: m.content })) }) }); if (res.ok) { const data = await res.json(); let assistantText = ''; - if (Array.isArray(data) && data[0]?.text) { - assistantText = data[0].text; - } else if (data && typeof data.text === 'string') { - assistantText = data.text; + let assistantId: string | undefined = undefined; + let assistantRating: 'good' | 'bad' | null = null; + + if (Array.isArray(data) && data[0]) { + assistantText = data[0].text || data[0].content || ''; + assistantId = data[0].id; + assistantRating = data[0].rating || null; + } else if (data && typeof data === 'object') { + assistantText = data.text || data.content || ''; + assistantId = data.id; + assistantRating = data.rating || null; } else if (typeof data === 'string') { assistantText = data; } else { assistantText = JSON.stringify(data); } - setMessages([...newMessages, { role: 'assistant', content: assistantText }]); + setMessages([ + ...newMessages, + { + id: assistantId, + role: 'assistant', + content: assistantText, + rating: assistantRating, + } + ]); } 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.' + } + ]); } } catch (e) { - setMessages([...newMessages, { role: 'assistant', content: 'Error de conexión con el servidor.' }]); + setMessages([ + ...newMessages, + { role: 'assistant', content: 'Error de conexión con el servidor.' } + ]); } finally { setLoading(false); } }; + // Valorar respuesta para aprendizaje continuo + const handleRateMessage = async (messageId: string | undefined, index: number, targetRating: 'good' | 'bad') => { + if (!messageId) return; + + const currentMsg = messages[index]; + const previousRating = currentMsg?.rating; + const newRating = previousRating === targetRating ? null : targetRating; + + // Actualización optimista + setMessages((prev) => + prev.map((m, i) => (i === index ? { ...m, rating: newRating, ratingLoading: true } : m)) + ); + + try { + const res = await fetch('/api/chat/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messageId, + rating: newRating, + }), + }); + + if (res.ok) { + if (newRating === 'good') { + showToast('¡Respuesta guardada! Claude tomará este ejemplo para calibrar futuras respuestas.'); + } else if (newRating === 'bad') { + showToast('Feedback registrado. Claude evitará repetir este enfoque o falta de concreción.'); + } else { + showToast('Valoración retirada.'); + } + } else { + // Rollback + setMessages((prev) => + prev.map((m, i) => (i === index ? { ...m, rating: previousRating } : m)) + ); + showToast('No se pudo guardar la valoración.'); + } + } catch (err) { + setMessages((prev) => + prev.map((m, i) => (i === index ? { ...m, rating: previousRating } : m)) + ); + showToast('Error de conexión al guardar la valoración.'); + } finally { + setMessages((prev) => + prev.map((m, i) => (i === index ? { ...m, ratingLoading: false } : m)) + ); + } + }; + + // Limpiar / Reiniciar historial + const handleClearChat = async () => { + if (clearing) return; + setClearing(true); + try { + const res = await fetch('/api/chat', { method: 'DELETE' }); + if (res.ok) { + setMessages([DEFAULT_WELCOME_MESSAGE]); + setConfirmClear(false); + showToast('Historial reiniciado correctamente.'); + } else { + showToast('Error al reiniciar el historial.'); + } + } catch (err) { + showToast('Error de conexión al limpiar el historial.'); + } finally { + setClearing(false); + } + }; + return ( -
+
+ {/* Toast Notification */} + {toastMessage && ( +
+ + {toastMessage} +
+ )} + {/* Header */} -
+
-

Asistente IA Financiero & Growth

-

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

+
+

Asistente IA Financiero & Growth

+ + + Aprendizaje Activo + +
+

Motor Claude 4.5 Sonnet · Memoria persistente y calibración continua con feedback

+ +
+ {confirmClear ? ( +
+ ¿Borrar historial? + + +
+ ) : ( + + )} +
{/* Messages Feed */}
{messages.map((m, idx) => (
+ + {/* Barra de valoración para respuestas del asistente */} + {m.role === 'assistant' && ( +
+
+ ¿Resultado útil? + + + +
+ + {m.rating === 'good' && ( + + ✓ Registrado para el aprendizaje de Claude + + )} + {m.rating === 'bad' && ( + + ✕ Enfoque descartado para calibración + + )} + {!m.rating && m.id && ( + + Valora para calibrar el modelo + + )} +
+ )}
))} @@ -209,7 +472,7 @@ export default function AsistentePage() {
{/* Quick Prompts Chips */} -
+
{/* Input Box */} -
+
{ e.preventDefault(); diff --git a/src/lib/assistant-context.ts b/src/lib/assistant-context.ts index ddf2e1c..6442eee 100644 --- a/src/lib/assistant-context.ts +++ b/src/lib/assistant-context.ts @@ -42,7 +42,8 @@ export async function buildClientAssistantContext( adDailyRes, calendarDocsRes, creativesRes, - auditsRes + auditsRes, + ratedMessagesRes ] = 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(), @@ -53,7 +54,8 @@ export async function buildClientAssistantContext( 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) + admin.from('documents').select('title, content, updated_at').eq('tenant_id', tenantId).eq('type', 'audit').limit(3), + admin.from('chat_messages').select('id, role, content, tool_calls, created_at').eq('tenant_id', tenantId).not('tool_calls->>rating', 'is', null).order('created_at', { ascending: false }).limit(20) ]); const tenant = tenantRes.status === 'fulfilled' ? tenantRes.value.data : null; @@ -252,6 +254,29 @@ ${creatives.length > 0 ).join('\n') : '- Sin piezas creativas en pipeline.'}`; + // 12. Historial de feedback del usuario para Aprendizaje Continuo (Ratings good/bad) + const ratedRows = ratedMessagesRes.status === 'fulfilled' ? (ratedMessagesRes.value.data || []) : []; + const goodResponses = ratedRows + .filter((r: any) => r.role === 'assistant' && r.tool_calls?.rating === 'good') + .slice(0, 4); + const badResponses = ratedRows + .filter((r: any) => r.role === 'assistant' && r.tool_calls?.rating === 'bad') + .slice(0, 4); + + let feedbackSection = ''; + if (goodResponses.length > 0 || badResponses.length > 0) { + feedbackSection = ` +=== 🧠 APRENDIZAJE Y FEEDBACK ACUMULADO DEL USUARIO (PREFERENCIAS VALIDADAS) === +El usuario de este cliente ha calificado respuestas anteriores del Asistente para guiar tu aprendizaje continuo. Toma muy en cuenta este refuerzo: +${goodResponses.length > 0 ? ` +🟢 RESPUESTAS VALORADAS COMO EXCELENTES / BUEN RESULTADO (IMPRIME ESTE RIGOR, NIVEL DE DETALLE Y TONO DIRECTO): +${goodResponses.map((g: any, i: number) => `Ejemplo Aprobado ${i + 1}:\n"""${g.content.slice(0, 500)}${g.content.length > 500 ? '...' : ''}"""`).join('\n\n')}` : ''} +${badResponses.length > 0 ? ` +🔴 RESPUESTAS VALORADAS COMO MAL RESULTADO (EVITA ESTOS PATRONES, FALTA DE CONCRECIÓN, ALUCINACIONES O AMBIGÜEDADES): +${badResponses.map((b: any, i: number) => `Ejemplo a Corregir ${i + 1}:\n"""${b.content.slice(0, 350)}${b.content.length > 350 ? '...' : ''}"""`).join('\n\n')}` : ''} +`; + } + const contextPrompt = `DATOS COMPLETOS DEL CLIENTE EN TIEMPO REAL: ${brandSection} ${targetsSection} @@ -260,6 +285,7 @@ ${adsSection} ${kpisSection} ${calendarSection} ${creativesSection} +${feedbackSection} `; return {