diff --git a/ChatbotApi/Constrollers/AdminController.cs b/ChatbotApi/Constrollers/AdminController.cs index 8104c31..1298ead 100644 --- a/ChatbotApi/Constrollers/AdminController.cs +++ b/ChatbotApi/Constrollers/AdminController.cs @@ -2,6 +2,8 @@ using ChatbotApi.Data.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Caching.Memory; +using ChatbotApi.Services; namespace ChatbotApi.Controllers { @@ -11,10 +13,12 @@ namespace ChatbotApi.Controllers public class AdminController : ControllerBase { private readonly AppContexto _context; + private readonly IMemoryCache _cache; - public AdminController(AppContexto context) + public AdminController(AppContexto context, IMemoryCache cache) { _context = context; + _cache = cache; } // GET: api/admin/contexto @@ -36,6 +40,10 @@ namespace ChatbotApi.Controllers item.FechaActualizacion = DateTime.UtcNow; _context.ContextoItems.Add(item); await _context.SaveChangesAsync(); + + // Invalida la caché de KnowledgeItems + _cache.Remove(CacheKeys.KnowledgeItems); + return CreatedAtAction(nameof(GetAllContextoItems), new { id = item.Id }, item); } @@ -58,6 +66,9 @@ namespace ChatbotApi.Controllers existingItem.FechaActualizacion = DateTime.UtcNow; await _context.SaveChangesAsync(); + // Invalida la caché de KnowledgeItems + _cache.Remove(CacheKeys.KnowledgeItems); + return NoContent(); } @@ -72,6 +83,9 @@ namespace ChatbotApi.Controllers } _context.ContextoItems.Remove(item); await _context.SaveChangesAsync(); + // Invalida la caché de KnowledgeItems + _cache.Remove(CacheKeys.KnowledgeItems); + return NoContent(); } @@ -100,6 +114,10 @@ namespace ChatbotApi.Controllers { _context.FuentesDeContexto.Add(fuente); await _context.SaveChangesAsync(); + + // Invalida la caché de FuentesDeContexto + _cache.Remove(CacheKeys.FuentesDeContexto); + return CreatedAtAction(nameof(GetAllFuentes), new { id = fuente.Id }, fuente); } @@ -129,6 +147,9 @@ namespace ChatbotApi.Controllers } } + // Invalida la caché de FuentesDeContexto + _cache.Remove(CacheKeys.FuentesDeContexto); + return NoContent(); } @@ -144,6 +165,9 @@ namespace ChatbotApi.Controllers _context.FuentesDeContexto.Remove(fuente); await _context.SaveChangesAsync(); + // Invalida la caché de FuentesDeContexto + _cache.Remove(CacheKeys.FuentesDeContexto); + return NoContent(); } } diff --git a/ChatbotApi/Constrollers/ChatController.cs b/ChatbotApi/Constrollers/ChatController.cs index 3aa5599..10cfb1d 100644 --- a/ChatbotApi/Constrollers/ChatController.cs +++ b/ChatbotApi/Constrollers/ChatController.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Caching.Memory; using System.Runtime.CompilerServices; using System.Text.Json; using System.Globalization; +using ChatbotApi.Services; // Clases de Request/Response public class GenerationConfig @@ -51,9 +52,6 @@ namespace ChatbotApi.Controllers 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; @@ -207,7 +205,9 @@ namespace ChatbotApi.Controllers foreach (var fuente in fuentesExternas) { contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---"); - string scrapedContent = await ScrapeUrlContentAsync(fuente.Url); + + string scrapedContent = await ScrapeUrlContentAsync(fuente); + contextBuilder.AppendLine(scrapedContent); } @@ -272,7 +272,7 @@ namespace ChatbotApi.Controllers { 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("Eres DiaBot, el asistente virtual del periódico El Día. Tu personalidad es profesional, servicial y concisa. Responde siempre en español Rioplatense. El usuario se encuentra navegando en la web de eldia.com"); // CONTEXTO FIJO try { @@ -287,7 +287,7 @@ namespace ChatbotApi.Controllers 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) + catch (Exception ex) { _logger.LogWarning(ex, "No se pudo determinar la zona horaria de Argentina. El contexto de tiempo será omitido."); } @@ -501,7 +501,7 @@ namespace ChatbotApi.Controllers private async Task> GetKnowledgeItemsAsync() { - return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => + return await _cache.GetOrCreateAsync(CacheKeys.KnowledgeItems, async entry => { _logger.LogInformation("Cargando ContextoItems desde la base de datos a la caché..."); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); @@ -515,7 +515,7 @@ namespace ChatbotApi.Controllers private async Task> GetFuentesDeContextoAsync() { - return await _cache.GetOrCreateAsync(_fuentesCacheKey, async entry => + return await _cache.GetOrCreateAsync(CacheKeys.FuentesDeContexto, async entry => { _logger.LogInformation("Cargando FuentesDeContexto desde la base de datos a la caché..."); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); @@ -562,30 +562,47 @@ namespace ChatbotApi.Controllers } } - private async Task ScrapeUrlContentAsync(string url) + private async Task ScrapeUrlContentAsync(FuenteContexto fuente) { - return await _cache.GetOrCreateAsync($"scrape_{url}", async entry => + // La clave de caché sigue siendo la misma. + var result = await _cache.GetOrCreateAsync($"scrape_{fuente.Url}_{fuente.SelectorContenido}", async entry => { - _logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url); + _logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", fuente.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"); + var doc = await web.LoadFromWebAsync(fuente.Url); - if (mainContentNode == null) return string.Empty; + HtmlNode? contentNode; + string selectorUsado; - // 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) + // Si se especificó un selector en la base de datos, lo usamos. + if (!string.IsNullOrWhiteSpace(fuente.SelectorContenido)) { - sb.AppendLine(WebUtility.HtmlDecode(node.InnerText).Trim()); + selectorUsado = fuente.SelectorContenido; + contentNode = doc.DocumentNode.SelectSingleNode(selectorUsado); } - return sb.ToString(); - }) ?? string.Empty; + else + { + // Si no, usamos nuestro fallback genérico a
o . + selectorUsado = "//main | //body"; + contentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body"); + } + + if (contentNode == null) + { + _logger.LogWarning("No se encontró contenido en {Url} con el selector '{Selector}'", fuente.Url, selectorUsado); + return string.Empty; + } + + _logger.LogInformation("Extrayendo texto de {Url} usando el selector '{Selector}'", fuente.Url, selectorUsado); + + // --- LA LÓGICA CLAVE Y SIMPLIFICADA --- + // Extraemos TODO el texto visible dentro del nodo seleccionado, sin importar las etiquetas. + // InnerText es recursivo y obtiene el texto de todos los nodos hijos. + return WebUtility.HtmlDecode(contentNode.InnerText) ?? string.Empty; + }); + return result ?? string.Empty; } } } \ No newline at end of file diff --git a/ChatbotApi/Data/Models/FuenteContexto.cs b/ChatbotApi/Data/Models/FuenteContexto.cs index b8b6f8a..aec1705 100644 --- a/ChatbotApi/Data/Models/FuenteContexto.cs +++ b/ChatbotApi/Data/Models/FuenteContexto.cs @@ -16,7 +16,10 @@ public class FuenteContexto [Required] [MaxLength(500)] - public string DescripcionParaIA { get; set; } = null!; // ¡La parte más importante! + public string DescripcionParaIA { get; set; } = null!; public bool Activo { get; set; } = true; + + [MaxLength(200)] + public string? SelectorContenido { get; set; } // Ej: "div.contenedor-planes" } \ No newline at end of file diff --git a/ChatbotApi/Migrations/20251125135810_AddSelectorContenidoToFuentes.Designer.cs b/ChatbotApi/Migrations/20251125135810_AddSelectorContenidoToFuentes.Designer.cs new file mode 100644 index 0000000..f4cf37e --- /dev/null +++ b/ChatbotApi/Migrations/20251125135810_AddSelectorContenidoToFuentes.Designer.cs @@ -0,0 +1,368 @@ +// +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("20251125135810_AddSelectorContenidoToFuentes")] + partial class AddSelectorContenidoToFuentes + { + /// + 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("SelectorContenido") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + 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/20251125135810_AddSelectorContenidoToFuentes.cs b/ChatbotApi/Migrations/20251125135810_AddSelectorContenidoToFuentes.cs new file mode 100644 index 0000000..b6235f8 --- /dev/null +++ b/ChatbotApi/Migrations/20251125135810_AddSelectorContenidoToFuentes.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ChatbotApi.Migrations +{ + /// + public partial class AddSelectorContenidoToFuentes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SelectorContenido", + table: "FuentesDeContexto", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SelectorContenido", + table: "FuentesDeContexto"); + } + } +} diff --git a/ChatbotApi/Migrations/AppContextoModelSnapshot.cs b/ChatbotApi/Migrations/AppContextoModelSnapshot.cs index d48f446..ac4ab2e 100644 --- a/ChatbotApi/Migrations/AppContextoModelSnapshot.cs +++ b/ChatbotApi/Migrations/AppContextoModelSnapshot.cs @@ -97,6 +97,10 @@ namespace ChatbotApi.Migrations .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("SelectorContenido") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + b.Property("Url") .IsRequired() .HasMaxLength(1000) diff --git a/ChatbotApi/Services/CacheKeys.cs b/ChatbotApi/Services/CacheKeys.cs new file mode 100644 index 0000000..4c3ceda --- /dev/null +++ b/ChatbotApi/Services/CacheKeys.cs @@ -0,0 +1,9 @@ +// ChatbotApi/Services/CacheKeys.cs +namespace ChatbotApi.Services +{ + public static class CacheKeys + { + public const string KnowledgeItems = "KnowledgeBase"; + public const string FuentesDeContexto = "FuentesDeContexto"; + } +} \ No newline at end of file diff --git a/chatbot-admin/src/components/SourceManager.tsx b/chatbot-admin/src/components/SourceManager.tsx index b868724..de183b8 100644 --- a/chatbot-admin/src/components/SourceManager.tsx +++ b/chatbot-admin/src/components/SourceManager.tsx @@ -15,6 +15,7 @@ interface FuenteContexto { url: string; descripcionParaIA: string; activo: boolean; + selectorContenido?: string; } interface SourceManagerProps { @@ -169,6 +170,14 @@ const SourceManager: React.FC = ({ onAuthError }) => { 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 sobre el registro'." /> + setCurrentRow({ ...currentRow, selectorContenido: e.target.value })} + helperText="Selector CSS/XPath para apuntar al 'div' específico que contiene la información. Ej: //div[@id='precios']" + /> { {activeArticle && (
- Hablando sobre: + Enlace relacionado: {activeArticle.title}