feat(assistant): add persistent chat history, continuous learning and rating system (good/bad)

This commit is contained in:
Marcos Fargas
2026-10-18 01:57:52 +02:00
parent f034522e7b
commit 28eab60691
4 changed files with 545 additions and 29 deletions

View File

@@ -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,
});
}

View File

@@ -1,11 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { createClient as createAdminClient } from '@supabase/supabase-js';
import { resolveActiveTenant } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import { buildClientAssistantContext } from '@/lib/assistant-context'; import { buildClientAssistantContext } from '@/lib/assistant-context';
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
export const dynamic = 'force-dynamic'; 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) { 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?.trim() || ''; 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; let clientContext;
try { try {
clientContext = await buildClientAssistantContext( clientContext = await buildClientAssistantContext(
@@ -215,7 +313,36 @@ REGLAS Y PAUTAS INMUTABLES:
.map((b) => (b.type === 'text' ? b.text : '')) .map((b) => (b.type === 'text' ? b.text : ''))
.join(''); .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) { } catch (apiErr: any) {
console.error('Error al consultar la API de Anthropic Claude:', apiErr); console.error('Error al consultar la API de Anthropic Claude:', apiErr);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,7 +1,31 @@
"use client"; "use client";
import React, { useState, useRef, useEffect } from 'react'; 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 }) { function FormattedMessage({ content }: { content: string }) {
const lines = content.split('\n'); 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() { export default function AsistentePage() {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([ const [messages, setMessages] = useState<ChatMessage[]>([DEFAULT_WELCOME_MESSAGE]);
{
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 [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const showToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => {
setToastMessage((current) => (current === msg ? null : current));
}, 4000);
};
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}; };
// Cargar historial de chat persistente en Supabase
useEffect(() => { useEffect(() => {
scrollToBottom(); async function fetchChatHistory() {
}, [messages, loading]); 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 sendMessage = async (textToSend?: string) => {
const text = textToSend || input; const text = textToSend || input;
if (!text.trim() || loading) return; 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); setMessages(newMessages);
setInput(''); setInput('');
setLoading(true); setLoading(true);
@@ -135,46 +199,187 @@ export default function AsistentePage() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
message: text, message: text,
messages: newMessages messages: newMessages.map((m) => ({ role: m.role, content: m.content }))
}) })
}); });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
let assistantText = ''; let assistantText = '';
if (Array.isArray(data) && data[0]?.text) { let assistantId: string | undefined = undefined;
assistantText = data[0].text; let assistantRating: 'good' | 'bad' | null = null;
} else if (data && typeof data.text === 'string') {
assistantText = data.text; 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') { } else if (typeof data === 'string') {
assistantText = data; assistantText = data;
} else { } else {
assistantText = JSON.stringify(data); assistantText = JSON.stringify(data);
} }
setMessages([...newMessages, { role: 'assistant', content: assistantText }]); setMessages([
...newMessages,
{
id: assistantId,
role: 'assistant',
content: assistantText,
rating: assistantRating,
}
]);
} 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.'
}
]);
} }
} catch (e) { } 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 { } finally {
setLoading(false); 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 ( return (
<div className="flex flex-col h-[calc(100vh-10rem)] bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm"> <div className="flex flex-col h-[calc(100vh-10rem)] bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm relative">
{/* Toast Notification */}
{toastMessage && (
<div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 bg-gray-900 text-white px-4 py-2 rounded-xl text-xs shadow-lg flex items-center gap-2 animate-in fade-in slide-in-from-top-2 border border-gray-700">
<CheckCircle className="w-3.5 h-3.5 text-brand-400 shrink-0" />
<span>{toastMessage}</span>
</div>
)}
{/* Header */} {/* Header */}
<div className="p-4 border-b border-gray-200 flex items-center justify-between bg-white"> <div className="p-4 border-b border-gray-200 flex items-center justify-between bg-white shrink-0">
<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">
<Bot className="w-5 h-5" /> <Bot className="w-5 h-5" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & Growth</h3> <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> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Aprendizaje Activo
</span>
</div> </div>
<p className="text-[11px] text-gray-500">Motor Claude 4.5 Sonnet · Memoria persistente y calibración continua con feedback</p>
</div>
</div>
<div className="flex items-center gap-2">
{confirmClear ? (
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 px-2 py-1 rounded-lg">
<span className="text-xs text-rose-700 font-medium">¿Borrar historial?</span>
<button
type="button"
onClick={handleClearChat}
disabled={clearing}
className="text-xs font-semibold px-2 py-0.5 bg-rose-600 text-white rounded hover:bg-rose-700 cursor-pointer disabled:opacity-50"
>
{clearing ? 'Borrando...' : 'Sí, borrar'}
</button>
<button
type="button"
onClick={() => setConfirmClear(false)}
className="text-xs text-gray-600 px-1.5 py-0.5 hover:text-gray-900 cursor-pointer"
>
Cancelar
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmClear(true)}
title="Reiniciar conversación y limpiar historial"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-200 text-xs text-gray-600 hover:text-rose-600 hover:bg-rose-50/50 hover:border-rose-200 transition-colors cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Limpiar chat</span>
</button>
)}
</div> </div>
</div> </div>
@@ -182,7 +387,7 @@ export default function AsistentePage() {
<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
key={idx} key={m.id || idx}
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
> >
<div <div
@@ -193,6 +398,64 @@ export default function AsistentePage() {
}`} }`}
> >
<FormattedMessage content={m.content} /> <FormattedMessage content={m.content} />
{/* Barra de valoración para respuestas del asistente */}
{m.role === 'assistant' && (
<div className="mt-3 pt-2.5 border-t border-gray-200/80 flex flex-wrap items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-400 font-medium">¿Resultado útil?</span>
<button
type="button"
onClick={() => handleRateMessage(m.id, idx, 'good')}
disabled={!m.id || m.ratingLoading}
title={!m.id ? 'Mensaje inicial del sistema' : 'Buen resultado — Claude aprenderá de este formato'}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs transition-all ${
!m.id
? 'opacity-40 cursor-not-allowed border-gray-200 text-gray-400'
: m.rating === 'good'
? 'bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold shadow-xs cursor-pointer'
: 'bg-white text-gray-600 border-gray-200 hover:bg-emerald-50/60 hover:text-emerald-700 hover:border-emerald-200 cursor-pointer'
}`}
>
<ThumbsUp className={`w-3.5 h-3.5 ${m.rating === 'good' ? 'fill-emerald-600 text-emerald-600' : ''}`} />
<span>Buen resultado</span>
</button>
<button
type="button"
onClick={() => handleRateMessage(m.id, idx, 'bad')}
disabled={!m.id || m.ratingLoading}
title={!m.id ? 'Mensaje inicial del sistema' : 'Mal resultado — Claude evitará este enfoque'}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs transition-all ${
!m.id
? 'opacity-40 cursor-not-allowed border-gray-200 text-gray-400'
: m.rating === 'bad'
? 'bg-rose-50 text-rose-700 border-rose-300 font-semibold shadow-xs cursor-pointer'
: 'bg-white text-gray-600 border-gray-200 hover:bg-rose-50/60 hover:text-rose-700 hover:border-rose-200 cursor-pointer'
}`}
>
<ThumbsDown className={`w-3.5 h-3.5 ${m.rating === 'bad' ? 'fill-rose-600 text-rose-600' : ''}`} />
<span>Mal resultado</span>
</button>
</div>
{m.rating === 'good' && (
<span className="text-[11px] text-emerald-600 font-medium flex items-center gap-1">
✓ Registrado para el aprendizaje de Claude
</span>
)}
{m.rating === 'bad' && (
<span className="text-[11px] text-rose-600 font-medium flex items-center gap-1">
✕ Enfoque descartado para calibración
</span>
)}
{!m.rating && m.id && (
<span className="text-[10px] text-gray-400">
Valora para calibrar el modelo
</span>
)}
</div>
)}
</div> </div>
</div> </div>
))} ))}
@@ -209,7 +472,7 @@ export default function AsistentePage() {
</div> </div>
{/* Quick Prompts Chips */} {/* Quick Prompts Chips */}
<div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600"> <div className="px-6 py-2.5 bg-white/80 border-t border-gray-200 flex gap-2 overflow-x-auto text-xs text-gray-600 shrink-0">
<button <button
onClick={() => sendMessage('¿Qué puedo preguntar sobre el cliente?')} onClick={() => sendMessage('¿Qué puedo preguntar sobre el cliente?')}
className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium" className="px-3 py-1.5 rounded-full bg-brand-50 text-brand-700 hover:bg-brand-100 whitespace-nowrap transition-colors flex items-center gap-1.5 cursor-pointer border border-brand-200 font-medium"
@@ -262,7 +525,7 @@ export default function AsistentePage() {
</div> </div>
{/* Input Box */} {/* Input Box */}
<div className="p-4 border-t border-gray-200 bg-white"> <div className="p-4 border-t border-gray-200 bg-white shrink-0">
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();

View File

@@ -42,7 +42,8 @@ export async function buildClientAssistantContext(
adDailyRes, adDailyRes,
calendarDocsRes, calendarDocsRes,
creativesRes, creativesRes,
auditsRes auditsRes,
ratedMessagesRes
] = await Promise.allSettled([ ] = await Promise.allSettled([
admin.from('tenants').select('id, name, slug, settings').eq('id', tenantId).maybeSingle(), 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('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('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('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('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; const tenant = tenantRes.status === 'fulfilled' ? tenantRes.value.data : null;
@@ -252,6 +254,29 @@ ${creatives.length > 0
).join('\n') ).join('\n')
: '- Sin piezas creativas en pipeline.'}`; : '- 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: const contextPrompt = `DATOS COMPLETOS DEL CLIENTE EN TIEMPO REAL:
${brandSection} ${brandSection}
${targetsSection} ${targetsSection}
@@ -260,6 +285,7 @@ ${adsSection}
${kpisSection} ${kpisSection}
${calendarSection} ${calendarSection}
${creativesSection} ${creativesSection}
${feedbackSection}
`; `;
return { return {