// 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 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 enum IntentType { Article, Database, 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 _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 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) { 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); 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, 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. Si encuentras una noticia relevante, proporciona su enlace en formato Markdown: '[título](URL)'."; 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; 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."); 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; } 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; 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) { 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."); } } private async Task FindBestDbItemAsync(string userMessage, Dictionary 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("\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 { var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); if (!response.IsSuccessStatusCode) return null; var geminiResponse = await response.Content.ReadFromJsonAsync(); var bestKey = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim(); if (bestKey != null && knowledgeBase.TryGetValue(bestKey, out var contextValue)) { _logger.LogInformation("Búsqueda en DB por IA: La pregunta '{userMessage}' se asoció con la clave '{bestKey}'.", userMessage, bestKey); return contextValue; } _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, "Excepción en FindBestDbItemAsync."); return null; } } private async Task GetWebsiteNewsAsync(string url, int cantidad) { 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; } 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) { 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)) { 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; } } 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; } } 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() { return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => { _logger.LogInformation("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos..."); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); using (var scope = _serviceProvider.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); var knowledge = await dbContext.ContextoItems .AsNoTracking() .ToDictionaryAsync(item => item.Clave, item => item.Valor); _logger.LogInformation($"Caché actualizada con {knowledge.Count} items."); return knowledge; } }) ?? 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; } } } }