Feat: Principio Memoria de Hilo Conversacional

This commit is contained in:
2025-11-21 10:21:34 -03:00
parent 37b97eeb97
commit 1a46f15ec1
3 changed files with 135 additions and 26 deletions

View File

@@ -51,7 +51,7 @@ namespace ChatbotApi.Controllers
private static readonly string _siteUrl = "https://www.eldia.com/";
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
const int OutTokens = 8192;
public ChatController(IConfiguration configuration, IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger<ChatController> logger)
{
_logger = logger;
@@ -64,7 +64,45 @@ namespace ChatbotApi.Controllers
_apiUrl = $"{baseUrl}{apiKey}";
}
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent)
private async Task<string> UpdateConversationSummaryAsync(string? oldSummary, string userMessage, string botResponse)
{
if (string.IsNullOrWhiteSpace(oldSummary))
{
oldSummary = "Esta es una nueva conversación.";
}
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actualizar un resumen de conversación. Basado en el RESUMEN ANTERIOR y el ÚLTIMO INTERCAMBIO, crea un nuevo resumen conciso. Mantén solo los puntos clave y el tema principal de la conversación.");
promptBuilder.AppendLine("\n--- RESUMEN ANTERIOR ---");
promptBuilder.AppendLine(oldSummary);
promptBuilder.AppendLine("\n--- ÚLTIMO INTERCAMBIO ---");
promptBuilder.AppendLine($"Usuario: \"{userMessage}\"");
promptBuilder.AppendLine($"Bot: \"{new string(botResponse.Take(300).ToArray())}...\"");
promptBuilder.AppendLine("\n--- NUEVO RESUMEN CONCISO ---");
var finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
try
{
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
if (!response.IsSuccessStatusCode) return oldSummary ?? "";
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var newSummary = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
_logger.LogInformation("Resumen de conversación actualizado: '{NewSummary}'", newSummary);
return newSummary ?? oldSummary ?? "";
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en UpdateConversationSummaryAsync. Se mantendrá el resumen anterior.");
return oldSummary ?? "";
}
}
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary)
{
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones. Basado en la PREGUNTA DEL USUARIO, decide qué herramienta es la más apropiada para encontrar la respuesta. Responde única y exclusivamente con una de las siguientes tres opciones: [ARTICULO_ACTUAL], [BASE_DE_DATOS], [NOTICIAS_PORTADA].");
@@ -73,6 +111,12 @@ namespace ChatbotApi.Controllers
promptBuilder.AppendLine("[BASE_DE_DATOS]: Úsala si la pregunta es sobre información específica y general del diario, como datos de contacto (teléfono, dirección), publicidad o suscripciones.");
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales, eventos, o si ninguna de las otras herramientas parece adecuada.");
if (!string.IsNullOrWhiteSpace(conversationSummary))
{
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ACTUAL ---");
promptBuilder.AppendLine(conversationSummary);
}
if (!string.IsNullOrEmpty(activeArticleContent))
{
promptBuilder.AppendLine("\n--- CONVERSACIÓN ACTUAL (Contexto del artículo) ---");
@@ -133,8 +177,9 @@ namespace ChatbotApi.Controllers
{
articleContext = await GetArticleContentAsync(request.ContextUrl);
}
intent = await GetIntentAsync(userMessage, articleContext);
// Le pasamos el resumen al router de intenciones
intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary);
switch (intent)
{
@@ -147,7 +192,7 @@ namespace ChatbotApi.Controllers
case IntentType.Database:
_logger.LogInformation("Ejecutando intención: Base de Datos.");
var knowledgeBase = await GetKnowledgeAsync();
context = await FindBestDbItemAsync(userMessage, knowledgeBase) ?? "No se encontró información relevante en la base de datos.";
context = await FindBestDbItemAsync(userMessage, request.ConversationSummary, knowledgeBase) ?? "No se encontró información relevante en la base de datos.";
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene información específica (contacto, suscripciones, etc.).";
break;
@@ -174,6 +219,8 @@ namespace ChatbotApi.Controllers
}
Stream? responseStream = null;
var fullBotReply = new StringBuilder();
try
{
var promptBuilder = new StringBuilder();
@@ -222,7 +269,6 @@ namespace ChatbotApi.Controllers
yield break;
}
var fullBotReply = new StringBuilder();
if (responseStream != null)
{
await using (responseStream)
@@ -258,7 +304,14 @@ namespace ChatbotApi.Controllers
if (fullBotReply.Length > 0)
{
// Guardamos el log de la conversación como antes
await SaveConversationLogAsync(userMessage, fullBotReply.ToString());
// Creamos el nuevo resumen
var newSummary = await UpdateConversationSummaryAsync(request.ConversationSummary, userMessage, fullBotReply.ToString());
// Enviamos el nuevo resumen al frontend como el último mensaje del stream
yield return $"SUMMARY::{newSummary}";
}
}
@@ -285,14 +338,22 @@ namespace ChatbotApi.Controllers
}
}
private async Task<string?> FindBestDbItemAsync(string userMessage, Dictionary<string, string> knowledgeBase)
private async Task<string?> FindBestDbItemAsync(string userMessage, string? conversationSummary, Dictionary<string, string> knowledgeBase)
{
if (knowledgeBase == null || !knowledgeBase.Any()) return null;
var availableKeys = string.Join(", ", knowledgeBase.Keys);
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un buscador semántico. Basado en la PREGUNTA DEL USUARIO, elige la CLAVE más relevante de la LISTA DE CLAVES DISPONIBLES. Responde única y exclusivamente con la clave que elijas.");
promptBuilder.AppendLine("Tu tarea es actuar como un buscador semántico. Usa el RESUMEN para entender el contexto de la conversación. Basado en la PREGUNTA DEL USUARIO, elige la CLAVE más relevante de la lista. Responde única y exclusivamente con la clave que elijas.");
// Añadimos el resumen al prompt del buscador
if (!string.IsNullOrWhiteSpace(conversationSummary))
{
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ---");
promptBuilder.AppendLine(conversationSummary);
}
promptBuilder.AppendLine("\n--- LISTA DE CLAVES DISPONIBLES ---");
promptBuilder.AppendLine(availableKeys);
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");

View File

@@ -6,4 +6,5 @@ public class ChatRequest
[MaxLength(200)]
public required string Message { get; set; }
public string? ContextUrl { get; set; }
public string? ConversationSummary { get; set; }
}

View File

@@ -14,6 +14,7 @@ const MAX_CHARS = 200;
// Constantes para la clave del localStorage
const CHAT_HISTORY_KEY = 'chatbot-history';
const CHAT_CONTEXT_KEY = 'chatbot-active-article';
const CHAT_SUMMARY_KEY = 'chatbot-summary';
const Chatbot: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
@@ -51,6 +52,23 @@ const Chatbot: React.FC = () => {
return null;
});
const [conversationSummary, setConversationSummary] = useState<string>(() => {
try {
return localStorage.getItem(CHAT_SUMMARY_KEY) || "";
} catch (error) {
console.error("No se pudo cargar el resumen de la conversación:", error);
return "";
}
});
useEffect(() => {
try {
localStorage.setItem(CHAT_SUMMARY_KEY, conversationSummary);
} catch (error) {
console.error("No se pudo guardar el resumen de la conversación:", error);
}
}, [conversationSummary]);
// Añadimos un useEffect para guardar los mensajes.
useEffect(() => {
try {
@@ -102,14 +120,14 @@ const Chatbot: React.FC = () => {
const messageToSend = inputValue;
setInputValue('');
// Inicia el estado de carga, pero aún no el de streaming
setIsLoading(true);
setIsStreaming(false);
try {
const requestBody = {
message: messageToSend,
contextUrl: activeArticle ? activeArticle.url : null
contextUrl: activeArticle ? activeArticle.url : null,
conversationSummary: conversationSummary
};
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/chat/stream-message`, {
@@ -133,22 +151,41 @@ const Chatbot: React.FC = () => {
const { done, value } = await reader.read();
if (done) {
// ... (La lógica de `if (done)` no cambia)
try {
const responseArray = JSON.parse(fullReplyRaw);
let intent = 'Homepage';
let messageChunks = [];
if (Array.isArray(responseArray) && responseArray.length > 0) {
if (typeof responseArray[0] === 'string' && responseArray[0].startsWith('INTENT::')) {
intent = responseArray[0].split('::')[1];
messageChunks = responseArray.slice(1);
} else {
messageChunks = responseArray;
}
const messageChunks: string[] = [];
let finalSummary = conversationSummary;
if (Array.isArray(responseArray)) {
responseArray.forEach((item: string) => {
if (typeof item === 'string') {
if (item.startsWith('INTENT::')) {
intent = item.split('::')[1];
} else if (item.startsWith('SUMMARY::')) {
finalSummary = item.split('::')[1];
} else {
messageChunks.push(item);
}
}
});
}
setConversationSummary(finalSummary);
const finalCleanText = messageChunks.join('');
// 1. Aseguramos que el último mensaje tenga el texto final y 100% limpio.
setMessages(prev => {
const updatedMessages = [...prev];
if (updatedMessages.length > 0) {
updatedMessages[updatedMessages.length - 1].text = finalCleanText;
}
return updatedMessages;
});
const linkRegex = /\[(.*?)\]\((https?:\/\/[^\s]+)\)/;
const match = finalCleanText.match(linkRegex);
if (match && match[1] && match[2]) {
setActiveArticle({ title: match[1], url: match[2] });
} else if (intent === 'Database' || intent === 'Homepage') {
@@ -164,26 +201,37 @@ const Chatbot: React.FC = () => {
const chunk = decoder.decode(value);
fullReplyRaw += chunk;
// --- LÓGICA DE VISUALIZACIÓN EN TIEMPO REAL ---
let cleanTextForDisplay = '';
try {
const parsedArray = JSON.parse(fullReplyRaw.replace(/,$/, '') + ']');
const displayChunks = parsedArray[0] && parsedArray[0].startsWith('INTENT::')
? parsedArray.slice(1)
: parsedArray;
// Filtramos CUALQUIER item que sea una pista interna.
const displayChunks = Array.isArray(parsedArray)
? parsedArray.filter((item: string) =>
typeof item === 'string' && !item.startsWith('INTENT::') && !item.startsWith('SUMMARY::')
)
: [];
cleanTextForDisplay = displayChunks.join('');
} catch (e) {
cleanTextForDisplay = fullReplyRaw.replace(/^\["INTENT::.*?","|\["|"]$|","/g, '');
// El fallback también debe filtrar ambas pistas.
cleanTextForDisplay = fullReplyRaw
.replace(/\"INTENT::.*?\",?/g, '')
.replace(/\"SUMMARY::.*?\",?/g, '')
.replace(/^\["|"]$|","/g, '');
}
if (isFirstChunk) {
// En el primer chunk, activamos el flag de streaming
setIsStreaming(true);
setMessages(prev => [...prev, { text: cleanTextForDisplay, sender: 'bot' }]);
isFirstChunk = false;
} else {
setMessages(prev => {
const updatedMessages = [...prev];
updatedMessages[updatedMessages.length - 1].text = cleanTextForDisplay;
if (updatedMessages.length > 0) {
updatedMessages[updatedMessages.length - 1].text = cleanTextForDisplay;
}
return updatedMessages;
});
}
@@ -197,7 +245,6 @@ const Chatbot: React.FC = () => {
const errorText = error instanceof Error ? error.message : 'Lo siento, no pude conectarme.';
setMessages(prev => [...prev, { text: errorText, sender: 'bot' }]);
} finally {
// Al final, reseteamos ambos estados
setIsLoading(false);
setIsStreaming(false);
}