From c94936d56eee19598b717217ad56d600c7598fa9 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 20 Nov 2025 10:52:46 -0300 Subject: [PATCH] =?UTF-8?q?Fix:=20Detecci=C3=B3n=20de=20Contexto=20Seg?= =?UTF-8?q?=C3=BAn=20Tema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ChatbotApi/Constrollers/ChatController.cs | 364 +++++++++++----------- ChatbotApi/Models/ChatRequest.cs | 1 + chatbot-widget/src/components/Chatbot.css | 13 + chatbot-widget/src/components/Chatbot.tsx | 81 +++-- 4 files changed, 246 insertions(+), 213 deletions(-) diff --git a/ChatbotApi/Constrollers/ChatController.cs b/ChatbotApi/Constrollers/ChatController.cs index 6ecf791..36708a3 100644 --- a/ChatbotApi/Constrollers/ChatController.cs +++ b/ChatbotApi/Constrollers/ChatController.cs @@ -19,6 +19,7 @@ public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[ public class Candidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; } public class GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { get; set; } = default!; } public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; } +public enum IntentType { Article, Database, Homepage } namespace ChatbotApi.Controllers { @@ -28,10 +29,10 @@ namespace ChatbotApi.Controllers { private readonly string _apiUrl; private readonly IMemoryCache _cache; - private readonly IServiceProvider _serviceProvider; // Para crear un scope de DB + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private static readonly HttpClient _httpClient = new HttpClient(); - private static readonly string _knowledgeCacheKey = "KnowledgeBase"; // Clave única para nuestra caché + private static readonly string _knowledgeCacheKey = "KnowledgeBase"; private static readonly string _siteUrl = "https://www.eldia.com/"; private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " }; @@ -47,14 +48,57 @@ namespace ChatbotApi.Controllers var baseUrl = configuration["Gemini:GeminiApiUrl"]; _apiUrl = $"{baseUrl}{apiKey}"; } + + private async Task GetIntentAsync(string userMessage, string? activeArticleContent) + { + 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]."); + promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE HERRAMIENTAS ---"); + promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Úsala si la pregunta es una continuación directa de la conversación y trata sobre el artículo que se está discutiendo."); + 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.IsNullOrEmpty(activeArticleContent)) + { + promptBuilder.AppendLine("\n--- CONVERSACIÓN ACTUAL (Contexto del artículo) ---"); + promptBuilder.AppendLine(new string(activeArticleContent.Take(500).ToArray()) + "..."); + } + + promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---"); + promptBuilder.AppendLine(userMessage); + promptBuilder.AppendLine("\n--- HERRAMIENTA SELECCIONADA ---"); + + 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 IntentType.Homepage; + + var geminiResponse = await response.Content.ReadFromJsonAsync(); + var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? ""; + + _logger.LogInformation("Intención detectada: {Intent}", responseText); + + if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article; + if (responseText.Contains("BASE_DE_DATOS")) return IntentType.Database; + return IntentType.Homepage; + } + catch (Exception ex) + { + _logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage."); + return IntentType.Homepage; + } + } + [HttpPost("stream-message")] [EnableRateLimiting("fixed")] public async IAsyncEnumerable StreamMessage( [FromBody] ChatRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { - // --- FASE 1: Validación y Preparación --- if (string.IsNullOrWhiteSpace(request?.Message)) { yield return "Error: No he recibido ningún mensaje."; @@ -62,44 +106,64 @@ namespace ChatbotApi.Controllers } string userMessage = request.Message; - string lowerUserMessage = userMessage.ToLowerInvariant(); - string context; - string? errorMessage = null; // Variable para almacenar el mensaje de error + string context = ""; + string promptInstructions = ""; + string? articleContext = null; + string? errorMessage = null; try { - var knowledgeBase = await GetKnowledgeAsync(); - string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase); - context = dbContext ?? await GetWebsiteNewsAsync(_siteUrl, 15); + if (!string.IsNullOrEmpty(request.ContextUrl)) + { + articleContext = await GetArticleContentAsync(request.ContextUrl); + } + + IntentType intent = await GetIntentAsync(userMessage, articleContext); + + switch (intent) + { + case IntentType.Article: + _logger.LogInformation("Ejecutando intención: Artículo Actual."); + context = articleContext ?? "No se pudo cargar el artículo."; + promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto completo de una noticia."; + break; + + 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."; + 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; + + case IntentType.Homepage: + default: + _logger.LogInformation("Ejecutando intención: Noticias de Portada."); + context = await GetWebsiteNewsAsync(_siteUrl, 15); + promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada. Si encuentras una noticia relevante, proporciona su enlace en formato Markdown: '[título](URL)'."; + break; + } } catch (Exception ex) { - _logger.LogError(ex, "Error al obtener el contexto para el streaming."); - errorMessage = "Error: No se pudo obtener la información de contexto."; - context = string.Empty; // Aseguramos que el contexto no sea nulo + _logger.LogError(ex, "Error al procesar la intención y el contexto."); + errorMessage = "Error: Lo siento, estoy teniendo un problema técnico al procesar tu pregunta."; + context = string.Empty; + promptInstructions = string.Empty; } - // Si hubo un error en la fase anterior, lo devolvemos if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; yield break; } - if (string.IsNullOrWhiteSpace(context)) - { - yield return "Error: No pude obtener información para responder a tu pregunta."; - yield break; - } - - // --- FASE 2: Configuración de la Conexión --- Stream? responseStream = null; try { var promptBuilder = new StringBuilder(); promptBuilder.AppendLine("INSTRUCCIONES:"); promptBuilder.AppendLine("Eres DiaBot, el asistente virtual del periódico El Día. Tu personalidad es profesional, servicial y concisa."); - promptBuilder.AppendLine(GetContextFromDb(lowerUserMessage, await GetKnowledgeAsync()) != null ? "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.). No hables de noticias." : "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO DEL SITIO WEB' que contiene una lista de noticias. Si el usuario pide la URL de una noticia, DEBES proporcionarla en formato Markdown: '[texto del enlace](URL)'."); + promptBuilder.AppendLine(promptInstructions); promptBuilder.AppendLine("NUNCA INVENTES información. Si la respuesta no está en el contexto, indica amablemente que no encontraste la información."); promptBuilder.AppendLine("\nCONTEXTO:\n---"); promptBuilder.AppendLine(context); @@ -135,14 +199,12 @@ namespace ChatbotApi.Controllers errorMessage = "Error: Lo siento, estoy teniendo un problema técnico."; } - // Devolvemos el error de la fase de conexión si ocurrió if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; yield break; } - // --- FASE 3: Lectura y Devolución del Stream --- var fullBotReply = new StringBuilder(); if (responseStream != null) { @@ -155,22 +217,19 @@ namespace ChatbotApi.Controllers if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue; var jsonString = line.Substring(6); - string? chunk = null; // 1. Declaramos la variable 'chunk' fuera del try. + string? chunk = null; try { - // 2. El bloque try solo se encarga de la deserialización. var geminiResponse = JsonSerializer.Deserialize(jsonString); chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; } catch (JsonException ex) { - // 3. Si falla, lo registramos y pasamos al siguiente fragmento. _logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString); continue; } - // 4. El yield return ahora está fuera del bloque try-catch. if (chunk != null) { fullBotReply.Append(chunk); @@ -180,7 +239,6 @@ namespace ChatbotApi.Controllers } } - // --- FASE 4: Guardado final --- if (fullBotReply.Length > 0) { await SaveConversationLogAsync(userMessage, fullBotReply.ToString()); @@ -209,180 +267,100 @@ namespace ChatbotApi.Controllers _logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming."); } } - - [HttpPost("message")] - [EnableRateLimiting("fixed")] - public async Task PostMessage([FromBody] ChatRequest request) + + private async Task FindBestDbItemAsync(string userMessage, Dictionary knowledgeBase) { - if (string.IsNullOrWhiteSpace(request?.Message)) - { - return BadRequest(new ChatResponse { Reply = "No he recibido ningún mensaje." }); - } + 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("\n--- LISTA DE CLAVES DISPONIBLES ---"); + promptBuilder.AppendLine(availableKeys); + promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---"); + promptBuilder.AppendLine(userMessage); + promptBuilder.AppendLine("\n--- CLAVE MÁS RELEVANTE ---"); + + 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 { - string userMessage = request.Message; - string lowerUserMessage = userMessage.ToLowerInvariant(); - string context; - string promptInstructions; - - // 1. Obtenemos el conocimiento desde nuestro nuevo método de caché - var knowledgeBase = await GetKnowledgeAsync(); - string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase); - - if (dbContext != null) - { - context = dbContext; - 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.). No hables de noticias."; - } - // 2. Si no encontramos nada en la base de conocimiento, buscamos en las noticias. - else - { - context = await GetWebsiteNewsAsync(_siteUrl, 15); - promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO DEL SITIO WEB' que contiene una lista de noticias. Si el usuario pide la URL de una noticia, DEBES proporcionarla en formato Markdown: '[texto del enlace](URL)'."; - } - - if (string.IsNullOrWhiteSpace(context)) - { - return StatusCode(500, new ChatResponse { Reply = "No pude obtener información para responder a tu pregunta." }); - } - - var promptBuilder = new StringBuilder(); - promptBuilder.AppendLine("INSTRUCCIONES:"); - promptBuilder.AppendLine("Eres DiaBot, el asistente virtual del periódico El Día. Tu personalidad es profesional, servicial y concisa. Debes hablar en español 'Rioplatense'."); - promptBuilder.AppendLine(promptInstructions); // Instrucciones dinámicas - promptBuilder.AppendLine("NUNCA INVENTES información. Si la respuesta no está en el contexto, indica amablemente que no encontraste la información."); - promptBuilder.AppendLine("\nCONTEXTO:\n---"); - promptBuilder.AppendLine(context); - promptBuilder.AppendLine("---\n\nPREGUNTA DEL USUARIO:\n---"); - promptBuilder.AppendLine(userMessage); - promptBuilder.AppendLine("---\n\nRESPUESTA:"); - string finalPrompt = promptBuilder.ToString(); - - var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } }; - var response = await _httpClient.PostAsJsonAsync(_apiUrl, requestData); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogWarning("La API de Gemini devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent); - return StatusCode(500, new ChatResponse { Reply = "Hubo un error al comunicarse con el asistente de IA." }); - } + var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); + if (!response.IsSuccessStatusCode) return null; var geminiResponse = await response.Content.ReadFromJsonAsync(); - string botReply = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "Lo siento, no pude procesar una respuesta."; + var bestKey = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim(); - _logger.LogInformation($"[DEBUG] Respuesta de Gemini: '{botReply}'"); - - try + if (bestKey != null && knowledgeBase.TryGetValue(bestKey, out var contextValue)) { - // Usamos el IServiceProvider para crear un scope de DbContext temporal y seguro. - using (var scope = _serviceProvider.CreateScope()) - { - // Renombramos la variable para evitar conflictos de ámbito. - var scopedDbContext = scope.ServiceProvider.GetRequiredService(); - - var logEntry = new ConversacionLog - { - UsuarioMensaje = userMessage, - BotRespuesta = botReply, - Fecha = DateTime.UtcNow - }; - - scopedDbContext.ConversacionLogs.Add(logEntry); - await scopedDbContext.SaveChangesAsync(); - } + _logger.LogInformation("Búsqueda en DB por IA: La pregunta '{userMessage}' se asoció con la clave '{bestKey}'.", userMessage, bestKey); + return contextValue; } - catch (Exception logEx) - { - _logger.LogError(logEx, "Error al intentar guardar el log de la conversación en la base de datos."); - } - return Ok(new ChatResponse { Reply = botReply }); + + _logger.LogWarning("Búsqueda en DB por IA: La clave devuelta '{bestKey}' no es válida.", bestKey); + return null; } catch (Exception ex) { - _logger.LogError(ex, "Error inesperado al procesar el mensaje del usuario."); - return StatusCode(500, new ChatResponse { Reply = "Lo siento, estoy teniendo un problema técnico." }); + _logger.LogError(ex, "Excepción en FindBestDbItemAsync."); + return null; } } - // Método para buscar contexto en la caché de la DB - - private string? GetContextFromDb(string lowerUserMessage, Dictionary knowledgeBase) - { - // 1. Definimos una lista de palabras comunes a ignorar para hacer la búsqueda más precisa. - var stopWords = new HashSet - { - "el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "a", "ante", - "con", "contra", "desde", "en", "entre", "hacia", "hasta", "para", "por", "segun", - "sin", "sobre", "tras", "y", "o", "que", "cual", "cuales", "como", "cuando", "donde", - "quien", "es", "soy", "estoy", "mi", "mis", "quiero", "necesito", "saber", "dime", "dame", - "informacion", "acerca", "mas" - }; - - // 2. Separamos el mensaje del usuario en palabras individuales, eliminando las stop words. - var userWords = lowerUserMessage - .Split(new[] { ' ', ',', '.', '?', '!', '¿', '¡' }, StringSplitOptions.RemoveEmptyEntries) - .Where(word => !stopWords.Contains(word)) - .ToHashSet(); // Usamos un HashSet para búsquedas de palabras muy rápidas. - - if (!userWords.Any()) - { - return null; // Si solo había stop words, no buscamos nada. - } - - // 3. Iteramos sobre la base de conocimiento para encontrar la mejor coincidencia. - foreach (var kvp in knowledgeBase) - { - // Separamos las claves compuestas ("contacto_telefono" -> ["contacto", "telefono"]) - var keywords = kvp.Key.Split('_'); - - // 4. Comprobamos si ALGUNA de las palabras clave de la BD coincide con ALGUNA de las palabras del usuario. - if (keywords.Any(k => userWords.Contains(k))) - { - _logger.LogInformation("Contexto encontrado por coincidencia de palabra clave. Clave de BD: '{DbKey}', Palabra de usuario encontrada: '{MatchedWord}'", - kvp.Key, - string.Join(", ", keywords.Where(k => userWords.Contains(k)))); - - return kvp.Value; // Devolvemos el valor correspondiente a la primera clave que coincida. - } - } - - return null; // No se encontró ninguna coincidencia. - } - private async Task GetWebsiteNewsAsync(string url, int cantidad) { try { var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(url); - var nodosDeEnlace = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]/a[@href]"); - if (nodosDeEnlace == null || !nodosDeEnlace.Any()) return string.Empty; + var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]"); + + if (articleNodes == null || !articleNodes.Any()) + { + _logger.LogWarning("No se encontraron nodos de
en la URL {Url}", url); + return string.Empty; + } var contextBuilder = new StringBuilder(); contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:"); var urlsProcesadas = new HashSet(); int count = 0; - foreach (var nodoEnlace in nodosDeEnlace) + foreach (var articleNode in articleNodes) { - var urlRelativa = nodoEnlace.GetAttributeValue("href", string.Empty); - if (string.IsNullOrEmpty(urlRelativa) || urlRelativa == "#" || urlsProcesadas.Contains(urlRelativa)) continue; + var linkNode = articleNode.SelectSingleNode(".//a[@href]"); + var titleNode = articleNode.SelectSingleNode(".//h2"); - var nodoTitulo = nodoEnlace.SelectSingleNode(".//h1 | .//h2"); - if (nodoTitulo != null) + if (linkNode != null && titleNode != null) { - var textoLimpio = CleanTitleText(nodoTitulo.InnerText); - var urlCompleta = urlRelativa.StartsWith("/") ? new Uri(new Uri(url), urlRelativa).ToString() : urlRelativa; - contextBuilder.AppendLine($"- Título: \"{textoLimpio}\", URL: {urlCompleta}"); - urlsProcesadas.Add(urlRelativa); + var relativeUrl = linkNode.GetAttributeValue("href", string.Empty); + + if (string.IsNullOrEmpty(relativeUrl) || relativeUrl == "#" || urlsProcesadas.Contains(relativeUrl)) + { + continue; + } + + var cleanTitle = CleanTitleText(titleNode.InnerText); + var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl; + + contextBuilder.AppendLine($"- Título: \"{cleanTitle}\", URL: {fullUrl}"); + urlsProcesadas.Add(relativeUrl); count++; } - if (count >= cantidad) break; + + if (count >= cantidad) + { + break; + } } - return contextBuilder.ToString(); + + var result = contextBuilder.ToString(); + _logger.LogInformation("Scraping de la portada exitoso. Se encontraron {Count} noticias.", count); + return result; } catch (Exception ex) { @@ -405,32 +383,58 @@ namespace ChatbotApi.Controllers } return textoDecodificado; } + private async Task> GetKnowledgeAsync() { - // Intenta obtener el diccionario de la caché. - // Si no existe, el segundo argumento (la función factory) se ejecutará. return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => { _logger.LogInformation("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos..."); - - // Establecemos un tiempo de expiración para la caché. - // Después de 5 minutos, la caché se considerará inválida y se recargará en la siguiente petición. entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - - // Usamos IServiceProvider para crear un 'scope' de servicios temporal. - // Esto es necesario porque el DbContext tiene un tiempo de vida por petición (scoped), - // y esta función factory podría ejecutarse fuera de ese contexto. using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); var knowledge = await dbContext.ContextoItems - .AsNoTracking() // Mejora el rendimiento para consultas de solo lectura + .AsNoTracking() .ToDictionaryAsync(item => item.Clave, item => item.Valor); - _logger.LogInformation($"Caché actualizada con {knowledge.Count} items."); return knowledge; } - }) ?? new Dictionary(); // Si todo falla, devuelve un diccionario vacío. + }) ?? new Dictionary(); + } + + private async Task GetArticleContentAsync(string url) + { + try + { + var web = new HtmlWeb(); + var doc = await web.LoadFromWebAsync(url); + + var paragraphs = doc.DocumentNode.SelectNodes("//div[contains(@class, 'cuerpo_nota')]//p"); + + if (paragraphs == null || !paragraphs.Any()) + { + _logger.LogWarning("No se encontraron párrafos en la URL {Url} con el selector '//div[contains(@class, 'cuerpo_nota')]//p'.", url); + return null; + } + + var articleText = new StringBuilder(); + foreach (var p in paragraphs) + { + var cleanText = WebUtility.HtmlDecode(p.InnerText).Trim(); + if (!string.IsNullOrWhiteSpace(cleanText)) + { + articleText.AppendLine(cleanText); + } + } + + _logger.LogInformation("Se extrajo con éxito el contenido del artículo de {Url}", url); + return articleText.ToString(); + } + catch (Exception ex) + { + _logger.LogError(ex, "No se pudo descargar o procesar el contenido del artículo de la URL {Url}", url); + return null; + } } } } \ No newline at end of file diff --git a/ChatbotApi/Models/ChatRequest.cs b/ChatbotApi/Models/ChatRequest.cs index 11f0d9a..3936d2b 100644 --- a/ChatbotApi/Models/ChatRequest.cs +++ b/ChatbotApi/Models/ChatRequest.cs @@ -5,4 +5,5 @@ public class ChatRequest [Required] [MaxLength(200)] public required string Message { get; set; } + public string? ContextUrl { get; set; } } \ No newline at end of file diff --git a/chatbot-widget/src/components/Chatbot.css b/chatbot-widget/src/components/Chatbot.css index 614e531..77b7bf8 100644 --- a/chatbot-widget/src/components/Chatbot.css +++ b/chatbot-widget/src/components/Chatbot.css @@ -137,4 +137,17 @@ opacity: 0.8; text-align: right; margin-top: 4px; margin-right: 15px; +} + +.context-indicator { + padding: 5px 15px; + background-color: #e9e9eb; + font-size: 0.8rem; + color: #555; + text-align: center; + border-top: 1px solid #ccc; +} + +.context-indicator span { + font-weight: bold; } \ No newline at end of file diff --git a/chatbot-widget/src/components/Chatbot.tsx b/chatbot-widget/src/components/Chatbot.tsx index c8d3343..ffea068 100644 --- a/chatbot-widget/src/components/Chatbot.tsx +++ b/chatbot-widget/src/components/Chatbot.tsx @@ -34,6 +34,7 @@ const Chatbot: React.FC = () => { const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); const messagesEndRef = useRef(null); + const [activeArticleUrl, setActiveArticleUrl] = useState(null); // Añadimos un useEffect para guardar los mensajes. useEffect(() => { @@ -78,10 +79,15 @@ const Chatbot: React.FC = () => { setMessages(prev => [...prev, botMessagePlaceholder]); try { + const requestBody = { + message: messageToSend, + contextUrl: activeArticleUrl + }; + const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/chat/stream-message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: messageToSend }), + body: JSON.stringify(requestBody), }); if (!response.ok || !response.body) { @@ -90,50 +96,54 @@ const Chatbot: React.FC = () => { const reader = response.body.getReader(); const decoder = new TextDecoder(); - let accumulatedResponse = ''; // Variable para acumular el texto crudo const readStream = async () => { + let fullReply = ''; + while (true) { const { done, value } = await reader.read(); - if (done) { - // El stream ha terminado, no hacemos nada más aquí. + let finalCleanText = ''; + try { + const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']'); + finalCleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply; + } catch (e) { + finalCleanText = fullReply.replace(/^\["|"]$|","/g, ''); + } + + const linkRegex = /\[.*?\]\((https?:\/\/[^\s]+)\)/; + const match = finalCleanText.match(linkRegex); + + // --- INICIO DE LA CORRECCIÓN --- + // Si encontramos un nuevo enlace, actualizamos el contexto. + // Si NO encontramos un enlace, ya no hacemos nada, permitiendo que el contexto anterior persista. + if (match && match[1]) { + console.log("Noticia activa establecida:", match[1]); + setActiveArticleUrl(match[1]); + } + // HEMOS ELIMINADO EL BLOQUE "ELSE" QUE RESETEABA EL CONTEXTO. + // --- FIN DE LA CORRECCIÓN --- + break; } - // Acumulamos la respuesta cruda que viene del backend - accumulatedResponse += decoder.decode(value); + // ... (el resto del bucle while sigue exactamente igual) + const chunk = decoder.decode(value); + fullReply += chunk; + let cleanText = ''; try { - // Intentamos limpiar la respuesta acumulada - // 1. Parseamos como si fuera un array JSON - const parsedArray = JSON.parse(accumulatedResponse - // Añadimos un corchete de cierre por si el stream se corta a la mitad - .replace(/,$/, '') + ']'); - - // 2. Unimos los fragmentos del array en un solo texto - const cleanText = Array.isArray(parsedArray) ? parsedArray.join('') : accumulatedResponse; - - // 3. Actualizamos el estado con el texto limpio - setMessages(prev => { - const lastMessage = prev[prev.length - 1]; - const updatedLastMessage = { ...lastMessage, text: cleanText }; - return [...prev.slice(0, -1), updatedLastMessage]; - }); - + const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']'); + cleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply; } catch (e) { - // Si hay un error de parseo (porque el JSON aún no está completo), - // mostramos el texto sin los caracteres iniciales/finales. - const partiallyCleanedText = accumulatedResponse - .replace(/^\[?"|"?,"?|"?\]$/g, '') - .replace(/","/g, ''); - - setMessages(prev => { - const lastMessage = prev[prev.length - 1]; - const updatedLastMessage = { ...lastMessage, text: partiallyCleanedText }; - return [...prev.slice(0, -1), updatedLastMessage]; - }); + cleanText = fullReply.replace(/^\["|"]$|","/g, ''); } + + setMessages(prev => { + const lastMessage = prev[prev.length - 1]; + const updatedLastMessage = { ...lastMessage, text: cleanText }; + return [...prev.slice(0, -1), updatedLastMessage]; + }); } }; @@ -176,6 +186,11 @@ const Chatbot: React.FC = () => { ))}
+ {activeArticleUrl && ( +
+ Hablando sobre: Noticia actual +
+ )}