// ChatbotApi/Controllers/ChatController.cs using Microsoft.AspNetCore.Mvc; using ChatbotApi.Models; 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; // Clases de Request/Response public class GeminiRequest { [JsonPropertyName("contents")] public Content[] Contents { get; set; } = default!; } 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!; } namespace ChatbotApi.Controllers { [ApiController] [Route("api/[controller]")] public class ChatController : ControllerBase { private readonly string _apiUrl; private readonly IMemoryCache _cache; private readonly IServiceProvider _serviceProvider; // Para crear un scope de DB 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 _siteUrl = "https://www.eldia.com/"; private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " }; 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}"; } [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."; yield break; } string userMessage = request.Message; string lowerUserMessage = userMessage.ToLowerInvariant(); string context; string? errorMessage = null; // Variable para almacenar el mensaje de error try { var knowledgeBase = await GetKnowledgeAsync(); string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase); context = dbContext ?? await GetWebsiteNewsAsync(_siteUrl, 15); } 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 } // 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("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 } } } } }; 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 de Gemini (Streaming) devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent); throw new HttpRequestException("La API de Gemini devolvió un error."); } responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); } catch (TaskCanceledException) { _logger.LogInformation("La operación fue cancelada por el cliente durante la configuración del stream."); yield break; } catch (Exception ex) { _logger.LogError(ex, "Error inesperado durante la configuración del stream."); 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) { 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; // 1. Declaramos la variable 'chunk' fuera del try. 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); yield return chunk; } } } } // --- FASE 4: Guardado final --- if (fullBotReply.Length > 0) { await SaveConversationLogAsync(userMessage, fullBotReply.ToString()); } } 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."); } } [HttpPost("message")] [EnableRateLimiting("fixed")] public async Task PostMessage([FromBody] ChatRequest request) { if (string.IsNullOrWhiteSpace(request?.Message)) { return BadRequest(new ChatResponse { Reply = "No he recibido ningún mensaje." }); } 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 geminiResponse = await response.Content.ReadFromJsonAsync(); string botReply = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "Lo siento, no pude procesar una respuesta."; _logger.LogInformation($"[DEBUG] Respuesta de Gemini: '{botReply}'"); try { // 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(); } } 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 }); } 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." }); } } // 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 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) { var urlRelativa = nodoEnlace.GetAttributeValue("href", string.Empty); if (string.IsNullOrEmpty(urlRelativa) || urlRelativa == "#" || urlsProcesadas.Contains(urlRelativa)) continue; var nodoTitulo = nodoEnlace.SelectSingleNode(".//h1 | .//h2"); if (nodoTitulo != 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); count++; } if (count >= cantidad) break; } return contextBuilder.ToString(); } catch (Exception ex) { _logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url); return string.Empty; } } 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> 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 .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. } } }