diff --git a/ChatbotApi/Constrollers/AdminController.cs b/ChatbotApi/Constrollers/AdminController.cs index 002e410..8104c31 100644 --- a/ChatbotApi/Constrollers/AdminController.cs +++ b/ChatbotApi/Constrollers/AdminController.cs @@ -86,5 +86,65 @@ namespace ChatbotApi.Controllers .ToListAsync(); return Ok(logs); } + + // ENDPOINTS PARA FUENTES DE CONTEXTO (URLs) + [HttpGet("fuentes")] + public async Task GetAllFuentes() + { + var fuentes = await _context.FuentesDeContexto.OrderBy(f => f.Nombre).ToListAsync(); + return Ok(fuentes); + } + + [HttpPost("fuentes")] + public async Task CreateFuente([FromBody] FuenteContexto fuente) + { + _context.FuentesDeContexto.Add(fuente); + await _context.SaveChangesAsync(); + return CreatedAtAction(nameof(GetAllFuentes), new { id = fuente.Id }, fuente); + } + + [HttpPut("fuentes/{id}")] + public async Task UpdateFuente(int id, [FromBody] FuenteContexto fuente) + { + if (id != fuente.Id) + { + return BadRequest(); + } + + _context.Entry(fuente).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!_context.FuentesDeContexto.Any(e => e.Id == id)) + { + return NotFound(); + } + else + { + throw; + } + } + + return NoContent(); + } + + [HttpDelete("fuentes/{id}")] + public async Task DeleteFuente(int id) + { + var fuente = await _context.FuentesDeContexto.FindAsync(id); + if (fuente == null) + { + return NotFound(); + } + + _context.FuentesDeContexto.Remove(fuente); + await _context.SaveChangesAsync(); + + return NoContent(); + } } } \ No newline at end of file diff --git a/ChatbotApi/Constrollers/ChatController.cs b/ChatbotApi/Constrollers/ChatController.cs index 192e76b..6f3ffe8 100644 --- a/ChatbotApi/Constrollers/ChatController.cs +++ b/ChatbotApi/Constrollers/ChatController.cs @@ -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 } +public enum IntentType { Article, Database, Homepage, ExternalSource } namespace ChatbotApi.Controllers { @@ -102,14 +102,34 @@ namespace ChatbotApi.Controllers } } - private async Task GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary) + private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary knowledgeBase) { 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("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 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."); + 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}"); if (!string.IsNullOrWhiteSpace(conversationSummary)) { @@ -119,13 +139,13 @@ namespace ChatbotApi.Controllers if (!string.IsNullOrEmpty(activeArticleContent)) { - promptBuilder.AppendLine("\n--- CONVERSACIÓN ACTUAL (Contexto del artículo) ---"); + 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--- HERRAMIENTA SELECCIONADA ---"); + promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---"); var finalPrompt = promptBuilder.ToString(); var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } }; @@ -134,24 +154,36 @@ namespace ChatbotApi.Controllers try { var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); - if (!response.IsSuccessStatusCode) return IntentType.Homepage; + if (!response.IsSuccessStatusCode) return (IntentType.Homepage, null); var geminiResponse = await response.Content.ReadFromJsonAsync(); var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? ""; - _logger.LogInformation("Intención detectada: {Intent}", responseText); + _logger.LogInformation("Intención y datos detectados: {Response}", responseText); - if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article; - if (responseText.Contains("BASE_DE_DATOS")) return IntentType.Database; - return IntentType.Homepage; + 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); } catch (Exception ex) { _logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage."); - return IntentType.Homepage; + return (IntentType.Homepage, null); } } + [HttpPost("stream-message")] [EnableRateLimiting("fixed")] public async IAsyncEnumerable StreamMessage( @@ -178,8 +210,11 @@ namespace ChatbotApi.Controllers articleContext = await GetArticleContentAsync(request.ContextUrl); } - // Le pasamos el resumen al router de intenciones - intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary); + 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; switch (intent) { @@ -190,17 +225,38 @@ namespace ChatbotApi.Controllers break; case IntentType.Database: - _logger.LogInformation("Ejecutando intención: Base de Datos."); - var knowledgeBase = await GetKnowledgeAsync(); - context = await FindBestDbItemAsync(userMessage, request.ConversationSummary, 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.)."; + // --- 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)) + { + // 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(); + } + else + { + 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); + } + promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene la pregunta y respuesta encontrada."; 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)'."; + 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; } } @@ -338,55 +394,7 @@ namespace ChatbotApi.Controllers } } - private async Task FindBestDbItemAsync(string userMessage, string? conversationSummary, 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. Usa el RESUMEN para entender el contexto de la conversación. Basado en la PREGUNTA DEL USUARIO, elige la CLAVE más relevante de la lista. Responde única y exclusivamente con la clave que elijas."); - - // Añadimos el resumen al prompt del buscador - if (!string.IsNullOrWhiteSpace(conversationSummary)) - { - promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ---"); - promptBuilder.AppendLine(conversationSummary); - } - - 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) { @@ -462,7 +470,7 @@ namespace ChatbotApi.Controllers return textoDecodificado; } - private async Task> GetKnowledgeAsync() + private async Task> GetKnowledgeAsync() { return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => { @@ -471,13 +479,14 @@ namespace ChatbotApi.Controllers 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.Valor); + .ToDictionaryAsync(item => item.Clave, item => item); _logger.LogInformation($"Caché actualizada con {knowledge.Count} items."); return knowledge; } - }) ?? new Dictionary(); + }) ?? new Dictionary(); } private async Task GetArticleContentAsync(string url) @@ -514,5 +523,28 @@ namespace ChatbotApi.Controllers return null; } } + + 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 => + { + _logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url); + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Cachear por 1 hora + + 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); + }) ?? string.Empty; + } } } \ No newline at end of file diff --git a/ChatbotApi/Data/AppContexto.cs b/ChatbotApi/Data/AppContexto.cs index 84dc5f4..f74cf9d 100644 --- a/ChatbotApi/Data/AppContexto.cs +++ b/ChatbotApi/Data/AppContexto.cs @@ -9,5 +9,6 @@ namespace ChatbotApi.Data.Models public DbSet ContextoItems { get; set; } = null!; public DbSet ConversacionLogs { get; set; } = null!; + public DbSet FuentesDeContexto { get; set; } = null!; } } \ No newline at end of file diff --git a/ChatbotApi/Data/Models/FuenteContexto.cs b/ChatbotApi/Data/Models/FuenteContexto.cs new file mode 100644 index 0000000..b8b6f8a --- /dev/null +++ b/ChatbotApi/Data/Models/FuenteContexto.cs @@ -0,0 +1,22 @@ +// Data/Models/FuenteContexto.cs +using System.ComponentModel.DataAnnotations; + +public class FuenteContexto +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Nombre { get; set; } = null!; // Ej: "FAQs de Suscripción" + + [Required] + [MaxLength(1000)] + public string Url { get; set; } = null!; + + [Required] + [MaxLength(500)] + public string DescripcionParaIA { get; set; } = null!; // ¡La parte más importante! + + public bool Activo { get; set; } = true; +} \ No newline at end of file diff --git a/ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.Designer.cs b/ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.Designer.cs new file mode 100644 index 0000000..3372c39 --- /dev/null +++ b/ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.Designer.cs @@ -0,0 +1,364 @@ +// +using System; +using ChatbotApi.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ChatbotApi.Migrations +{ + [DbContext(typeof(AppContexto))] + [Migration("20251121141306_AddFuentesDeContextoTable")] + partial class AddFuentesDeContextoTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ChatbotApi.Data.Models.ContextoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Clave") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Descripcion") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FechaActualizacion") + .HasColumnType("datetime2"); + + b.Property("Valor") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.ToTable("ContextoItems"); + }); + + modelBuilder.Entity("ChatbotApi.Data.Models.ConversacionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BotRespuesta") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Fecha") + .HasColumnType("datetime2"); + + b.Property("UsuarioMensaje") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ConversacionLogs"); + }); + + modelBuilder.Entity("FuenteContexto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activo") + .HasColumnType("bit"); + + b.Property("DescripcionParaIA") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Nombre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.ToTable("FuentesDeContexto"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.cs b/ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.cs new file mode 100644 index 0000000..078c1af --- /dev/null +++ b/ChatbotApi/Migrations/20251121141306_AddFuentesDeContextoTable.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatbotApi.Migrations +{ + /// + public partial class AddFuentesDeContextoTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FuentesDeContexto", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Nombre = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Url = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + DescripcionParaIA = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Activo = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FuentesDeContexto", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FuentesDeContexto"); + } + } +} diff --git a/ChatbotApi/Migrations/AppContextoModelSnapshot.cs b/ChatbotApi/Migrations/AppContextoModelSnapshot.cs index ce09faa..d48f446 100644 --- a/ChatbotApi/Migrations/AppContextoModelSnapshot.cs +++ b/ChatbotApi/Migrations/AppContextoModelSnapshot.cs @@ -76,6 +76,37 @@ namespace ChatbotApi.Migrations b.ToTable("ConversacionLogs"); }); + modelBuilder.Entity("FuenteContexto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Activo") + .HasColumnType("bit"); + + b.Property("DescripcionParaIA") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Nombre") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.ToTable("FuentesDeContexto"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/chatbot-admin/src/components/AdminPanel.tsx b/chatbot-admin/src/components/AdminPanel.tsx index b524fc7..21f0aff 100644 --- a/chatbot-admin/src/components/AdminPanel.tsx +++ b/chatbot-admin/src/components/AdminPanel.tsx @@ -1,17 +1,16 @@ -// src/components/AdminPanel.tsx +// EN: src/components/AdminPanel.tsx import React, { useState } from 'react'; import { Box, Typography, AppBar, Toolbar, IconButton, Tabs, Tab } from '@mui/material'; import LogoutIcon from '@mui/icons-material/Logout'; -// Importamos los dos componentes que mostraremos en las pestañas -import ContextManager from './ContextManager'; // Renombraremos el AdminPanel original +import ContextManager from './ContextManager'; import LogsViewer from './LogsViewer'; +import SourceManager from './SourceManager'; interface AdminPanelProps { onLogout: () => void; } -// El componente se convierte en un contenedor con pestañas const AdminPanel: React.FC = ({ onLogout }) => { const [currentTab, setCurrentTab] = useState(0); @@ -33,12 +32,13 @@ const AdminPanel: React.FC = ({ onLogout }) => { + - {/* Mostramos el componente correspondiente a la pestaña activa */} {currentTab === 0 && } {currentTab === 1 && } + {currentTab === 2 && } ); }; diff --git a/chatbot-admin/src/components/SourceManager.tsx b/chatbot-admin/src/components/SourceManager.tsx new file mode 100644 index 0000000..6e6f1c3 --- /dev/null +++ b/chatbot-admin/src/components/SourceManager.tsx @@ -0,0 +1,208 @@ +// EN: src/components/SourceManager.tsx +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid'; +import type { GridColDef } from '@mui/x-data-grid'; +import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Alert, TextField, Chip, Switch, FormControlLabel } from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import apiClient from '../api/apiClient'; + +interface FuenteContexto { + id: number; + nombre: string; + url: string; + descripcionParaIA: string; + activo: boolean; +} + +interface SourceManagerProps { + onAuthError: () => void; +} + +const SourceManager: React.FC = ({ onAuthError }) => { + const [rows, setRows] = useState([]); + const [open, setOpen] = useState(false); + const [isEdit, setIsEdit] = useState(false); + const [currentRow, setCurrentRow] = useState>({}); + const [error, setError] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); + + const fetchData = useCallback(async () => { + try { + // --- ENDPOINT --- + const response = await apiClient.get('/api/admin/fuentes'); + setRows(response.data); + } catch (err) { + setError('No se pudieron cargar las fuentes de contexto.'); + if (axios.isAxiosError(err) && err.response?.status === 401) { + onAuthError(); + } + } + }, [onAuthError]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleOpen = (item?: FuenteContexto) => { + if (item) { + setIsEdit(true); + setCurrentRow(item); + } else { + // --- ESTADO INICIAL --- + setIsEdit(false); + setCurrentRow({ nombre: '', url: '', descripcionParaIA: '', activo: true }); + } + setOpen(true); + }; + + const handleClose = () => setOpen(false); + + const handleSave = async () => { + try { + if (isEdit) { + await apiClient.put(`/api/admin/fuentes/${currentRow.id}`, currentRow); + } else { + await apiClient.post('/api/admin/fuentes', currentRow); + } + fetchData(); + handleClose(); + } catch (err) { + setError('Error al guardar la fuente.'); + } + }; + + const handleDeleteClick = (id: number) => { + setItemToDelete(id); + setConfirmOpen(true); + }; + + const handleConfirmClose = () => { + setConfirmOpen(false); + setItemToDelete(null); + }; + + const handleConfirmDelete = async () => { + if (itemToDelete !== null) { + try { + await apiClient.delete(`/api/admin/fuentes/${itemToDelete}`); + fetchData(); + } catch (err) { + setError('Error al eliminar la fuente.'); + } finally { + handleConfirmClose(); + } + } + }; + + // --- DEFINICIÓN DE COLUMNAS --- + const columns: GridColDef[] = [ + { field: 'nombre', headerName: 'Nombre', width: 200 }, + { field: 'url', headerName: 'URL', width: 350 }, + { field: 'descripcionParaIA', headerName: 'Descripción para IA', flex: 1 }, + { + field: 'activo', + headerName: 'Activo', + width: 100, + renderCell: (params) => ( + + ), + }, + { + field: 'actions', + type: 'actions', + width: 100, + getActions: (params) => [ + } + label="Editar" + onClick={() => handleOpen(params.row as FuenteContexto)} + />, + } + label="Eliminar" + onClick={() => handleDeleteClick(params.id as number)} + />, + ], + }, + ]; + + return ( + + {error && {error}} + + + + + + {isEdit ? 'Editar Fuente' : 'Añadir Nueva Fuente'} + + setCurrentRow({ ...currentRow, nombre: e.target.value })} + helperText="Un nombre corto y descriptivo (ej: FAQs de Suscripción)." + /> + setCurrentRow({ ...currentRow, url: e.target.value })} + helperText="La URL completa de la página que el bot debe leer." + /> + 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'." + /> + setCurrentRow({ ...currentRow, activo: e.target.checked })} + /> + } + label="Fuente activa" + /> + + + + + + + + Confirmar Eliminación + + + ¿Estás seguro de que quieres eliminar este item? Esta acción no se puede deshacer. + + + + + + + + + ); +}; + +export default SourceManager; \ No newline at end of file