diff --git a/ChatbotApi/Constrollers/ChatController.cs b/ChatbotApi/Constrollers/ChatController.cs index 54b040b..56a2fad 100644 --- a/ChatbotApi/Constrollers/ChatController.cs +++ b/ChatbotApi/Constrollers/ChatController.cs @@ -33,6 +33,11 @@ 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 class NewsArticleLink +{ + public required string Title { get; set; } + public required string Url { get; set; } +} public enum IntentType { Article, KnowledgeBase, Homepage } namespace ChatbotApi.Controllers @@ -213,8 +218,34 @@ namespace ChatbotApi.Controllers case IntentType.Homepage: default: _logger.LogInformation("Ejecutando intención: Noticias de Portada."); - context = await GetWebsiteNewsAsync(_siteUrl, 25); - 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."; + + // 1. Obtenemos la lista de artículos de la portada. + var articles = await GetWebsiteNewsAsync(_siteUrl, 25); + + // 2. Usamos la IA para encontrar el mejor artículo. + var bestMatch = await FindBestMatchingArticleAsync(userMessage, articles); + + if (bestMatch != null) + { + // 3. SI ENCONTRAMOS UN ARTÍCULO: Scrapeamos su contenido y preparamos el prompt de síntesis. + _logger.LogInformation("Artículo relevante encontrado: {Title}", bestMatch.Title); + string articleContent = await GetArticleContentAsync(bestMatch.Url) ?? "No se pudo leer el contenido del artículo."; + context = articleContent; + promptInstructions = $"La pregunta del usuario es '{userMessage}'. Basado en el CONTEXTO (el contenido de un artículo), tu tarea es:\n1. Escribir un resumen muy conciso (una o dos frases) que responda directamente a la pregunta del usuario.\n2. Incluir el título completo del artículo y su enlace en formato Markdown: '[{bestMatch.Title}]({bestMatch.Url})'.\n3. Invitar amablemente al usuario a preguntar más sobre este tema."; + } + else + { + // 4. SI NO ENCONTRAMOS NADA: Fallback al comportamiento antiguo de mostrar la lista. + _logger.LogInformation("No se encontró un artículo específico. Mostrando un resumen general de la portada."); + var homepageContextBuilder = new StringBuilder(); + homepageContextBuilder.AppendLine("Lista de noticias principales extraídas de la página:"); + foreach (var article in articles) + { + homepageContextBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}"); + } + + promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote en la siguiente lista de noticias de portada. Si no encuentras una respuesta directa, informa al usuario sobre los temas principales disponibles."; + } break; } } @@ -352,62 +383,84 @@ namespace ChatbotApi.Controllers } } - private async Task GetWebsiteNewsAsync(string url, int cantidad) + private async Task> GetWebsiteNewsAsync(string url, int cantidad) { + var newsList = new List(); try { var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(url); - 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; - } + if (articleNodes == null) return newsList; - 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 articleNode in articleNodes) { + if (newsList.Count >= cantidad) break; + var linkNode = articleNode.SelectSingleNode(".//a[@href]"); var titleNode = articleNode.SelectSingleNode(".//h2"); if (linkNode != null && titleNode != null) { var relativeUrl = linkNode.GetAttributeValue("href", string.Empty); - - if (string.IsNullOrEmpty(relativeUrl) || relativeUrl == "#" || urlsProcesadas.Contains(relativeUrl)) + if (!string.IsNullOrEmpty(relativeUrl) && relativeUrl != "#" && !urlsProcesadas.Contains(relativeUrl)) { - continue; + var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl; + newsList.Add(new NewsArticleLink + { + Title = CleanTitleText(titleNode.InnerText), + Url = fullUrl + }); + urlsProcesadas.Add(relativeUrl); } - - 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; } } - - var result = contextBuilder.ToString(); - _logger.LogInformation("Scraping de la portada exitoso. Se encontraron {Count} noticias.", count); - return result; } catch (Exception ex) { _logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url); - return string.Empty; + } + return newsList; + } + + private async Task FindBestMatchingArticleAsync(string userMessage, List articles) + { + if (!articles.Any()) return null; + + var promptBuilder = new StringBuilder(); + promptBuilder.AppendLine("Tu tarea es actuar como un motor de búsqueda. Dada una PREGUNTA DE USUARIO y una LISTA DE ARTÍCULOS, debes encontrar el artículo más relevante. Responde única y exclusivamente con la URL completa del artículo elegido. Si ningún artículo es relevante, responde con 'N/A'."); + promptBuilder.AppendLine("\n--- LISTA DE ARTÍCULOS ---"); + foreach (var article in articles) + { + promptBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}"); + } + promptBuilder.AppendLine("\n--- PREGUNTA DE USUARIO ---"); + promptBuilder.AppendLine(userMessage); + promptBuilder.AppendLine("\n--- URL 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 + { + var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); + if (!response.IsSuccessStatusCode) return null; + + var geminiResponse = await response.Content.ReadFromJsonAsync(); + var responseUrl = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim(); + + if (string.IsNullOrEmpty(responseUrl) || responseUrl == "N/A") return null; + + // Buscamos el artículo completo en nuestra lista original usando la URL que nos dio la IA + return articles.FirstOrDefault(a => a.Url == responseUrl); + } + catch (Exception ex) + { + _logger.LogError(ex, "Excepción en FindBestMatchingArticleAsync."); + return null; } } diff --git a/chatbot-widget/src/components/Chatbot.tsx b/chatbot-widget/src/components/Chatbot.tsx index 8daf8b9..9650114 100644 --- a/chatbot-widget/src/components/Chatbot.tsx +++ b/chatbot-widget/src/components/Chatbot.tsx @@ -37,6 +37,7 @@ const Chatbot: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const messagesEndRef = useRef(null); + const inputRef = useRef(null); const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => { try { // 1. Intentamos obtener el contexto del artículo guardado. @@ -105,6 +106,24 @@ const Chatbot: React.FC = () => { } }, [messages, isOpen]); + // Este useEffect se encarga de gestionar el foco del campo de texto. + useEffect(() => { + // Solo aplicamos la lógica si la ventana del chat está abierta. + if (isOpen) { + // Si el bot NO está cargando, significa que el usuario puede escribir. + // Esto se cumple en dos escenarios: + // 1. Justo cuando se abre la ventana del chat. + // 2. Justo cuando el bot termina de responder (isLoading pasa de true a false). + if (!isLoading) { + // Usamos un pequeño retardo (100ms) para asegurar que el DOM se haya actualizado + // y cualquier animación de CSS haya terminado antes de intentar hacer foco. + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + } + }, [isOpen, isLoading]); // Las dependencias: se ejecuta si cambia `isOpen` o `isLoading`. + const toggleChat = () => setIsOpen(!isOpen); const handleInputChange = (event: React.ChangeEvent) => { @@ -292,6 +311,7 @@ const Chatbot: React.FC = () => {