feat(assistant): add persistent chat history, continuous learning and rating system (good/bad)
This commit is contained in:
100
src/app/api/chat/feedback/route.ts
Normal file
100
src/app/api/chat/feedback/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Array<{ role: string; content: string }>>([
|
||||
{
|
||||
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<ChatMessage[]>([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<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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 (
|
||||
<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 */}
|
||||
<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="w-8 h-8 rounded-lg bg-brand-500/20 text-brand-500 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<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 className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-sm text-gray-900">Asistente IA Financiero & Growth</h3>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* Messages Feed */}
|
||||
<div className="flex-1 p-6 overflow-y-auto space-y-4">
|
||||
{messages.map((m, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
key={m.id || idx}
|
||||
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
@@ -193,6 +398,64 @@ export default function AsistentePage() {
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
@@ -209,7 +472,7 @@ export default function AsistentePage() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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"
|
||||
@@ -262,7 +525,7 @@ export default function AsistentePage() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user