Fix-Feat: Invalida la caché al modificar fuentes de contexto. Añade selector de contenido de contexto.

Se soluciona un bug donde el chatbot no reconocía las nuevas fuentes de conocimiento o los items de contexto añadidos desde el panel de administración hasta que la API se reiniciaba.

Ahora, el AdminController borra la caché correspondiente después de cada operación de Crear, Actualizar o Eliminar. Esto fuerza al chatbot a recargar la información desde la base de datos en la siguiente petición, haciendo que los cambios se reflejen de forma inmediata.

Se añade un nuevo campo para determinar el selector de contenido dentro de la web al momento de realizar el scrap.
This commit is contained in:
2025-11-25 11:46:52 -03:00
parent 9245aae0ec
commit 119fea13a5
9 changed files with 489 additions and 26 deletions

View File

@@ -2,6 +2,8 @@
using ChatbotApi.Data.Models; using ChatbotApi.Data.Models;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Caching.Memory;
using ChatbotApi.Services;
namespace ChatbotApi.Controllers namespace ChatbotApi.Controllers
{ {
@@ -11,10 +13,12 @@ namespace ChatbotApi.Controllers
public class AdminController : ControllerBase public class AdminController : ControllerBase
{ {
private readonly AppContexto _context; private readonly AppContexto _context;
private readonly IMemoryCache _cache;
public AdminController(AppContexto context) public AdminController(AppContexto context, IMemoryCache cache)
{ {
_context = context; _context = context;
_cache = cache;
} }
// GET: api/admin/contexto // GET: api/admin/contexto
@@ -36,6 +40,10 @@ namespace ChatbotApi.Controllers
item.FechaActualizacion = DateTime.UtcNow; item.FechaActualizacion = DateTime.UtcNow;
_context.ContextoItems.Add(item); _context.ContextoItems.Add(item);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Invalida la caché de KnowledgeItems
_cache.Remove(CacheKeys.KnowledgeItems);
return CreatedAtAction(nameof(GetAllContextoItems), new { id = item.Id }, item); return CreatedAtAction(nameof(GetAllContextoItems), new { id = item.Id }, item);
} }
@@ -58,6 +66,9 @@ namespace ChatbotApi.Controllers
existingItem.FechaActualizacion = DateTime.UtcNow; existingItem.FechaActualizacion = DateTime.UtcNow;
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Invalida la caché de KnowledgeItems
_cache.Remove(CacheKeys.KnowledgeItems);
return NoContent(); return NoContent();
} }
@@ -72,6 +83,9 @@ namespace ChatbotApi.Controllers
} }
_context.ContextoItems.Remove(item); _context.ContextoItems.Remove(item);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Invalida la caché de KnowledgeItems
_cache.Remove(CacheKeys.KnowledgeItems);
return NoContent(); return NoContent();
} }
@@ -100,6 +114,10 @@ namespace ChatbotApi.Controllers
{ {
_context.FuentesDeContexto.Add(fuente); _context.FuentesDeContexto.Add(fuente);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Invalida la caché de FuentesDeContexto
_cache.Remove(CacheKeys.FuentesDeContexto);
return CreatedAtAction(nameof(GetAllFuentes), new { id = fuente.Id }, fuente); 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(); return NoContent();
} }
@@ -144,6 +165,9 @@ namespace ChatbotApi.Controllers
_context.FuentesDeContexto.Remove(fuente); _context.FuentesDeContexto.Remove(fuente);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
// Invalida la caché de FuentesDeContexto
_cache.Remove(CacheKeys.FuentesDeContexto);
return NoContent(); return NoContent();
} }
} }

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using System.Globalization; using System.Globalization;
using ChatbotApi.Services;
// Clases de Request/Response // Clases de Request/Response
public class GenerationConfig public class GenerationConfig
@@ -51,9 +52,6 @@ namespace ChatbotApi.Controllers
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ChatController> _logger; private readonly ILogger<ChatController> _logger;
private static readonly HttpClient _httpClient = new HttpClient(); 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 _siteUrl = "https://www.eldia.com/";
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " }; private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
const int OutTokens = 8192; const int OutTokens = 8192;
@@ -207,7 +205,9 @@ namespace ChatbotApi.Controllers
foreach (var fuente in fuentesExternas) foreach (var fuente in fuentesExternas)
{ {
contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---"); 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); contextBuilder.AppendLine(scrapedContent);
} }
@@ -272,7 +272,7 @@ namespace ChatbotApi.Controllers
{ {
var promptBuilder = new StringBuilder(); var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("INSTRUCCIONES:"); 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 // CONTEXTO FIJO
try try
{ {
@@ -501,7 +501,7 @@ namespace ChatbotApi.Controllers
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeItemsAsync() private async Task<Dictionary<string, ContextoItem>> 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é..."); _logger.LogInformation("Cargando ContextoItems desde la base de datos a la caché...");
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
@@ -515,7 +515,7 @@ namespace ChatbotApi.Controllers
private async Task<List<FuenteContexto>> GetFuentesDeContextoAsync() private async Task<List<FuenteContexto>> 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é..."); _logger.LogInformation("Cargando FuentesDeContexto desde la base de datos a la caché...");
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
@@ -562,30 +562,47 @@ namespace ChatbotApi.Controllers
} }
} }
private async Task<string> ScrapeUrlContentAsync(string url) private async Task<string> 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); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
var web = new HtmlWeb(); var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url); var doc = await web.LoadFromWebAsync(fuente.Url);
var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
if (mainContentNode == null) return string.Empty; HtmlNode? contentNode;
string selectorUsado;
// Extraer texto de etiquetas comunes de contenido // Si se especificó un selector en la base de datos, lo usamos.
var textNodes = mainContentNode.SelectNodes(".//p | .//h1 | .//h2 | .//h3 | .//li"); if (!string.IsNullOrWhiteSpace(fuente.SelectorContenido))
if (textNodes == null) return WebUtility.HtmlDecode(mainContentNode.InnerText);
var sb = new StringBuilder();
foreach (var node in textNodes)
{ {
sb.AppendLine(WebUtility.HtmlDecode(node.InnerText).Trim()); selectorUsado = fuente.SelectorContenido;
contentNode = doc.DocumentNode.SelectSingleNode(selectorUsado);
} }
return sb.ToString(); else
}) ?? string.Empty; {
// Si no, usamos nuestro fallback genérico a <main> o <body>.
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;
} }
} }
} }

View File

@@ -16,7 +16,10 @@ public class FuenteContexto
[Required] [Required]
[MaxLength(500)] [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; public bool Activo { get; set; } = true;
[MaxLength(200)]
public string? SelectorContenido { get; set; } // Ej: "div.contenedor-planes"
} }

View File

@@ -0,0 +1,368 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Clave")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Descripcion")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("FechaActualizacion")
.HasColumnType("datetime2");
b.Property<string>("Valor")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.HasKey("Id");
b.ToTable("ContextoItems");
});
modelBuilder.Entity("ChatbotApi.Data.Models.ConversacionLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("BotRespuesta")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("Fecha")
.HasColumnType("datetime2");
b.Property<string>("UsuarioMensaje")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("ConversacionLogs");
});
modelBuilder.Entity("FuenteContexto", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("Activo")
.HasColumnType("bit");
b.Property<string>("DescripcionParaIA")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Nombre")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("SelectorContenido")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.HasKey("Id");
b.ToTable("FuentesDeContexto");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("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<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("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<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ChatbotApi.Migrations
{
/// <inheritdoc />
public partial class AddSelectorContenidoToFuentes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SelectorContenido",
table: "FuentesDeContexto",
type: "nvarchar(200)",
maxLength: 200,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SelectorContenido",
table: "FuentesDeContexto");
}
}
}

View File

@@ -97,6 +97,10 @@ namespace ChatbotApi.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("nvarchar(100)"); .HasColumnType("nvarchar(100)");
b.Property<string>("SelectorContenido")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Url") b.Property<string>("Url")
.IsRequired() .IsRequired()
.HasMaxLength(1000) .HasMaxLength(1000)

View File

@@ -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";
}
}

View File

@@ -15,6 +15,7 @@ interface FuenteContexto {
url: string; url: string;
descripcionParaIA: string; descripcionParaIA: string;
activo: boolean; activo: boolean;
selectorContenido?: string;
} }
interface SourceManagerProps { interface SourceManagerProps {
@@ -169,6 +170,14 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
onChange={(e) => setCurrentRow({ ...currentRow, descripcionParaIA: e.target.value })} 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'." 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'."
/> />
<TextField
margin="dense"
label="Selector de Contenido (Opcional)"
fullWidth
value={currentRow.selectorContenido || ''}
onChange={(e) => 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']"
/>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch

View File

@@ -302,7 +302,7 @@ const Chatbot: React.FC = () => {
</div> </div>
{activeArticle && ( {activeArticle && (
<div className="context-indicator"> <div className="context-indicator">
Hablando sobre: Enlace relacionado:
<a href={activeArticle.url} target="_blank" rel="noopener noreferrer"> <a href={activeArticle.url} target="_blank" rel="noopener noreferrer">
{activeArticle.title} {activeArticle.title}
</a> </a>