// ChatbotApi/Controllers/ChatController.cs using Microsoft.AspNetCore.Mvc; using ChatbotApi.Data.Models; using System.Net; using System.Text; using System.Text.Json.Serialization; using HtmlAgilityPack; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Caching.Memory; using System.Runtime.CompilerServices; using System.Text.Json; using System.Globalization; // Clases de Request/Response public class GenerationConfig { [JsonPropertyName("maxOutputTokens")] public int MaxOutputTokens { get; set; } } public class GeminiRequest { [JsonPropertyName("contents")] public Content[] Contents { get; set; } = default!; [JsonPropertyName("generationConfig")] public GenerationConfig? GenerationConfig { get; set; } } public class Content { [JsonPropertyName("parts")] public Part[] Parts { get; set; } = default!; } public class Part { [JsonPropertyName("text")] public string Text { get; set; } = default!; } public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[] Candidates { get; set; } = default!; } 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 { [ApiController] [Route("api/[controller]")] public class ChatController : ControllerBase { private readonly string _apiUrl; private readonly IMemoryCache _cache; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private static readonly HttpClient _httpClient = new HttpClient(); private static readonly string _knowledgeCacheKey = "KnowledgeBase"; private static readonly string _fuentesCacheKey = "FuentesDeContexto"; 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 logger) { _logger = logger; _cache = memoryCache; _serviceProvider = serviceProvider; var apiKey = configuration["Gemini:GeminiApiKey"] ?? throw new InvalidOperationException("La API Key de Gemini no está configurada en .env"); var baseUrl = configuration["Gemini:GeminiApiUrl"]; _apiUrl = $"{baseUrl}{apiKey}"; } private async Task 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(); 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 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 conversación y la pregunta del usuario, elige la categoría de información necesaria. Responde única y exclusivamente con una de las siguientes tres opciones: [ARTICULO_ACTUAL], [BASE_DE_CONOCIMIENTO], [NOTICIAS_PORTADA]."); promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE CATEGORÍAS ---"); promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Si la pregunta es una continuación directa sobre el artículo que se está discutiendo."); promptBuilder.AppendLine("[BASE_DE_CONOCIMIENTO]: Si la pregunta es sobre información general del diario (contacto, registro, suscripciones, preguntas frecuentes, etc.)."); promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Para preguntas sobre noticias de último momento o eventos actuales."); if (!string.IsNullOrWhiteSpace(conversationSummary)) { promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ACTUAL ---"); promptBuilder.AppendLine(conversationSummary); } if (!string.IsNullOrEmpty(activeArticleContent)) { promptBuilder.AppendLine("\n--- CONTEXTO DEL ARTÍCULO ACTUAL ---"); promptBuilder.AppendLine(new string(activeArticleContent.Take(500).ToArray()) + "..."); } promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---"); promptBuilder.AppendLine(userMessage); promptBuilder.AppendLine("\n--- CATEGORÍA 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_CONOCIMIENTO")) return IntentType.KnowledgeBase; 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) { if (string.IsNullOrWhiteSpace(request?.Message)) { yield return "Error: No he recibido ningún mensaje."; yield break; } string userMessage = request.Message; string context = ""; string promptInstructions = ""; string? articleContext = null; string? errorMessage = null; IntentType intent = IntentType.Homepage; try { if (!string.IsNullOrEmpty(request.ContextUrl)) { articleContext = await GetArticleContentAsync(request.ContextUrl); } intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary); 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.KnowledgeBase: _logger.LogInformation("Ejecutando intención: Base de Conocimiento Unificada."); var contextBuilder = new StringBuilder(); contextBuilder.AppendLine("Usa la siguiente base de conocimiento para responder la pregunta del usuario:"); var knowledgeBaseItems = await GetKnowledgeItemsAsync(); foreach (var item in knowledgeBaseItems.Values) { contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}"); } var fuentesExternas = await GetFuentesDeContextoAsync(); foreach (var fuente in fuentesExternas) { contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---"); string scrapedContent = await ScrapeUrlContentAsync(fuente.Url); contextBuilder.AppendLine(scrapedContent); } context = contextBuilder.ToString(); promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en la 'BASE DE CONOCIMIENTO' proporcionada."; break; case IntentType.Homepage: default: _logger.LogInformation("Ejecutando intención: Noticias de Portada."); // 1. Obtenemos la lista de artículos de la portada. var articles = await GetWebsiteNewsAsync(_siteUrl, 50); // 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}"); } context = homepageContextBuilder.ToString(); 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; } } catch (Exception ex) { _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."; } yield return $"INTENT::{intent}"; if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; yield break; } Stream? responseStream = null; var fullBotReply = new StringBuilder(); 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. Responde siempre en español Rioplatense."); // CONTEXTO FIJO try { // Forzamos la zona horaria de Argentina para ser independientes de la configuración del servidor. var argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); var localTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone); var formattedTime = localTime.ToString("dddd, dd/MM/yyyy HH:mm 'Hs.'", new CultureInfo("es-AR")); promptBuilder.AppendLine("\n--- CONTEXTO FIJO ESPACIO-TEMPORAL (Tu Identidad) ---"); promptBuilder.AppendLine($"Tu base de operaciones y el foco principal de tus noticias es La Plata, Provincia de Buenos Aires, Argentina."); promptBuilder.AppendLine($"La fecha y hora actual en La Plata es: {formattedTime}."); promptBuilder.AppendLine("Usa esta información para dar contexto a las noticias y responder preguntas sobre el día o la ubicación."); promptBuilder.AppendLine("--------------------------------------------------"); } catch(Exception ex) { _logger.LogWarning(ex, "No se pudo determinar la zona horaria de Argentina. El contexto de tiempo será omitido."); } 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); promptBuilder.AppendLine("---\n\nPREGUNTA DEL USUARIO:\n---"); promptBuilder.AppendLine(userMessage); promptBuilder.AppendLine("---\n\nRESPUESTA:"); string finalPrompt = promptBuilder.ToString(); var streamingApiUrl = _apiUrl; var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } }, GenerationConfig = new GenerationConfig { MaxOutputTokens = OutTokens } }; var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, streamingApiUrl); httpRequestMessage.Content = JsonContent.Create(requestData); var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); _logger.LogWarning("La API (Streaming) devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent); throw new HttpRequestException("La API devolvió un error."); } responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Error inesperado durante la configuración del stream."); errorMessage = "Error: Lo siento, estoy teniendo un problema técnico."; } if (!string.IsNullOrEmpty(errorMessage)) { yield return errorMessage; yield break; } if (responseStream != null) { await using (responseStream) using (var reader = new StreamReader(responseStream)) { string? line; while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue; var jsonString = line.Substring(6); string? chunk = null; try { var geminiResponse = JsonSerializer.Deserialize(jsonString); chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; } catch (JsonException ex) { _logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString); continue; } if (chunk != null) { fullBotReply.Append(chunk); yield return chunk; } } } } 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}"; } } private async Task SaveConversationLogAsync(string userMessage, string botReply) { try { using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); var logEntry = new ConversacionLog { UsuarioMensaje = userMessage, BotRespuesta = botReply, Fecha = DateTime.UtcNow }; dbContext.ConversacionLogs.Add(logEntry); await dbContext.SaveChangesAsync(); } } catch (Exception logEx) { _logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming."); } } 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) return newsList; var urlsProcesadas = new HashSet(); 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)) { 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); } } } } catch (Exception ex) { _logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url); } 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; } } private string CleanTitleText(string texto) { if (string.IsNullOrWhiteSpace(texto)) return string.Empty; var textoDecodificado = WebUtility.HtmlDecode(texto).Trim(); foreach (var prefijo in PrefijosAQuitar) { if (textoDecodificado.StartsWith(prefijo, StringComparison.OrdinalIgnoreCase)) { textoDecodificado = textoDecodificado.Substring(prefijo.Length).Trim(); break; } } return textoDecodificado; } private async Task> GetKnowledgeItemsAsync() { return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => { _logger.LogInformation("Cargando ContextoItems desde la base de datos a la caché..."); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); return await dbContext.ContextoItems.AsNoTracking().ToDictionaryAsync(item => item.Clave, item => item); } }) ?? new Dictionary(); } private async Task> GetFuentesDeContextoAsync() { return await _cache.GetOrCreateAsync(_fuentesCacheKey, async entry => { _logger.LogInformation("Cargando FuentesDeContexto desde la base de datos a la caché..."); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); return await dbContext.FuentesDeContexto.Where(f => f.Activo).AsNoTracking().ToListAsync(); } }) ?? new List(); } 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; } } private async Task ScrapeUrlContentAsync(string url) { return await _cache.GetOrCreateAsync($"scrape_{url}", async entry => { _logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(url); var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body"); if (mainContentNode == null) return string.Empty; // Extraer texto de etiquetas comunes de contenido var textNodes = mainContentNode.SelectNodes(".//p | .//h1 | .//h2 | .//h3 | .//li"); if (textNodes == null) return WebUtility.HtmlDecode(mainContentNode.InnerText); var sb = new StringBuilder(); foreach (var node in textNodes) { sb.AppendLine(WebUtility.HtmlDecode(node.InnerText).Trim()); } return sb.ToString(); }) ?? string.Empty; } } }