diff --git a/ChatbotApi/Constrollers/ChatController.cs b/ChatbotApi/Constrollers/ChatController.cs index 6f3ffe8..54b040b 100644 --- a/ChatbotApi/Constrollers/ChatController.cs +++ b/ChatbotApi/Constrollers/ChatController.cs @@ -1,6 +1,5 @@ // ChatbotApi/Controllers/ChatController.cs using Microsoft.AspNetCore.Mvc; -using ChatbotApi.Models; using ChatbotApi.Data.Models; using System.Net; using System.Text; @@ -10,6 +9,7 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Caching.Memory; using System.Runtime.CompilerServices; using System.Text.Json; +using Microsoft.EntityFrameworkCore; // Clases de Request/Response public class GenerationConfig @@ -33,7 +33,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, ExternalSource } +public enum IntentType { Article, KnowledgeBase, Homepage } namespace ChatbotApi.Controllers { @@ -47,6 +47,7 @@ namespace ChatbotApi.Controllers 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. " }; @@ -57,9 +58,7 @@ namespace ChatbotApi.Controllers _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 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}"; } @@ -102,34 +101,14 @@ namespace ChatbotApi.Controllers } } - private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary knowledgeBase) + 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..."); - promptBuilder.AppendLine("- [ARTICULO_ACTUAL]"); - promptBuilder.AppendLine("- [NOTICIAS_PORTADA]"); - promptBuilder.AppendLine("- [BASE_DE_DATOS:CLAVE_SELECCIONADA]"); - - // --- LÓGICA DINÁMICA --- - List fuentesExternas; - using (var scope = _serviceProvider.CreateScope()) - { - var dbContext = scope.ServiceProvider.GetRequiredService(); - fuentesExternas = await dbContext.FuentesDeContexto.Where(f => f.Activo).ToListAsync(); - } - - foreach (var fuente in fuentesExternas) - { - promptBuilder.AppendLine($"[FUENTE_EXTERNA:{fuente.Url}]: Úsala si la pregunta trata sobre: {fuente.DescripcionParaIA}"); - } - - promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE HERRAMIENTAS ---"); - promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Úsala si la pregunta es una continuación directa sobre el artículo que se está discutiendo."); - promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales o eventos."); - promptBuilder.AppendLine("[BASE_DE_DATOS:CLAVE_SELECCIONADA]: Úsala para preguntas sobre información específica del diario. DEBES reemplazar 'CLAVE_SELECCIONADA' con la clave más relevante de la siguiente lista:"); - - var dbKeys = string.Join(", ", knowledgeBase.Keys); - promptBuilder.AppendLine($" - Claves disponibles: {dbKeys}"); + 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)) { @@ -145,7 +124,7 @@ namespace ChatbotApi.Controllers promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---"); promptBuilder.AppendLine(userMessage); - promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---"); + promptBuilder.AppendLine("\n--- CATEGORÍA SELECCIONADA ---"); var finalPrompt = promptBuilder.ToString(); var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } }; @@ -154,36 +133,24 @@ namespace ChatbotApi.Controllers try { var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); - if (!response.IsSuccessStatusCode) return (IntentType.Homepage, null); + 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 y datos detectados: {Response}", responseText); + _logger.LogInformation("Intención detectada: {Intent}", responseText); - if (responseText.Contains("ARTICULO_ACTUAL")) return (IntentType.Article, null); - if (responseText.Contains("NOTICIAS_PORTADA")) return (IntentType.Homepage, null); - if (responseText.Contains("BASE_DE_DATOS:")) - { - var key = responseText.Split(new[] { ':' }, 2)[1].TrimEnd(']'); - return (IntentType.Database, key); - } - if (responseText.Contains("FUENTE_EXTERNA:")) - { - var url = responseText.Split(new[] { ':' }, 2, StringSplitOptions.None)[1].TrimEnd(']'); - return (IntentType.ExternalSource, url); // Necesitaremos un nuevo IntentType - } - - return (IntentType.Homepage, null); + 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, null); + return IntentType.Homepage; } } - [HttpPost("stream-message")] [EnableRateLimiting("fixed")] public async IAsyncEnumerable StreamMessage( @@ -210,11 +177,7 @@ namespace ChatbotApi.Controllers articleContext = await GetArticleContentAsync(request.ContextUrl); } - var knowledgeBase = await GetKnowledgeAsync(); - - // --- CORRECCIÓN 2: El código que llama a GetIntentAsync debe esperar una tupla --- - var (detectedIntent, intentData) = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary, knowledgeBase); - intent = detectedIntent; + intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary); switch (intent) { @@ -224,24 +187,27 @@ namespace ChatbotApi.Controllers 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: - // --- CORRECCIÓN 3: La lógica aquí debe manejar la clave recibida --- - _logger.LogInformation("Ejecutando intención: Base de Datos con clave '{Key}'.", intentData); - if (intentData != null && knowledgeBase.TryGetValue(intentData, out var dbItem)) + 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) { - // Ahora dbItem es un objeto ContextoItem, no un string. - var dbContextBuilder = new StringBuilder(); - dbContextBuilder.AppendLine("Aquí tienes la información solicitada:"); - dbContextBuilder.AppendLine($"- PREGUNTA: {dbItem.Descripcion}"); - dbContextBuilder.AppendLine($" RESPUESTA: {dbItem.Valor}"); - context = dbContextBuilder.ToString(); + contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}"); } - else + + var fuentesExternas = await GetFuentesDeContextoAsync(); + foreach (var fuente in fuentesExternas) { - context = "No se encontró información relevante para la clave solicitada."; - _logger.LogWarning("La clave '{Key}' devuelta por la IA no es válida.", intentData); + contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---"); + string scrapedContent = await ScrapeUrlContentAsync(fuente.Url); + contextBuilder.AppendLine(scrapedContent); } - promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene la pregunta y respuesta encontrada."; + + 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: @@ -250,14 +216,6 @@ namespace ChatbotApi.Controllers 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."; break; - case IntentType.ExternalSource: - _logger.LogInformation("Ejecutando intención: Fuente Externa con URL '{Url}'.", intentData); - if (!string.IsNullOrEmpty(intentData)) - { - context = await ScrapeUrlContentAsync(intentData); - } - promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto de una página web."; - break; } } catch (Exception ex) @@ -394,8 +352,6 @@ namespace ChatbotApi.Controllers } } - - private async Task GetWebsiteNewsAsync(string url, int cantidad) { try @@ -470,25 +426,34 @@ namespace ChatbotApi.Controllers return textoDecodificado; } - private async Task> GetKnowledgeAsync() + private async Task> GetKnowledgeItemsAsync() { return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => { - _logger.LogInformation("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos..."); + _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(); - // Usamos ToDictionaryAsync para obtener el objeto ContextoItem completo. - var knowledge = await dbContext.ContextoItems - .AsNoTracking() - .ToDictionaryAsync(item => item.Clave, item => item); - _logger.LogInformation($"Caché actualizada con {knowledge.Count} items."); - return knowledge; + 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 @@ -526,24 +491,27 @@ namespace ChatbotApi.Controllers private async Task ScrapeUrlContentAsync(string url) { - // Usamos la URL como clave de caché para no scrapear la misma página una y otra vez. - return await _cache.GetOrCreateAsync(url, async entry => + return await _cache.GetOrCreateAsync($"scrape_{url}", async entry => { _logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url); - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Cachear por 1 hora + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30); var web = new HtmlWeb(); var doc = await web.LoadFromWebAsync(url); - - // Selector genérico que intenta obtener el contenido principal - // Esto puede necesitar ajustes dependiendo de la estructura de las páginas var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body"); if (mainContentNode == null) return string.Empty; - // Podríamos hacer esto mucho más inteligente, buscando

,

,
  • , etc. - // pero para empezar, InnerText es un buen punto de partida. - return WebUtility.HtmlDecode(mainContentNode.InnerText); + // 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; } } diff --git a/chatbot-admin/src/components/SourceManager.tsx b/chatbot-admin/src/components/SourceManager.tsx index 6e6f1c3..b868724 100644 --- a/chatbot-admin/src/components/SourceManager.tsx +++ b/chatbot-admin/src/components/SourceManager.tsx @@ -167,7 +167,7 @@ const SourceManager: React.FC = ({ onAuthError }) => { rows={3} value={currentRow.descripcionParaIA || ''} onChange={(e) => setCurrentRow({ ...currentRow, descripcionParaIA: e.target.value })} - helperText="¡Crucial! Describe en una frase para qué sirve esta fuente. Ej: 'Usar para responder preguntas sobre cómo registrarse, iniciar sesión o por qué es obligatorio el registro'." + helperText="¡Crucial! Describe en una frase para qué sirve esta fuente. Ej: 'Usar para responder preguntas sobre cómo registrarse, iniciar sesión o sobre el registro'." />