Feat Gestion de Fuentes URLs

This commit is contained in:
2025-11-21 11:20:44 -03:00
parent 1a46f15ec1
commit 2e353cbb8c
9 changed files with 832 additions and 77 deletions

View File

@@ -86,5 +86,65 @@ namespace ChatbotApi.Controllers
.ToListAsync();
return Ok(logs);
}
// ENDPOINTS PARA FUENTES DE CONTEXTO (URLs)
[HttpGet("fuentes")]
public async Task<IActionResult> GetAllFuentes()
{
var fuentes = await _context.FuentesDeContexto.OrderBy(f => f.Nombre).ToListAsync();
return Ok(fuentes);
}
[HttpPost("fuentes")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> DeleteFuente(int id)
{
var fuente = await _context.FuentesDeContexto.FindAsync(id);
if (fuente == null)
{
return NotFound();
}
_context.FuentesDeContexto.Remove(fuente);
await _context.SaveChangesAsync();
return NoContent();
}
}
}

View File

@@ -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<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary)
private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary<string, ContextoItem> 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<FuenteContexto> fuentesExternas;
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
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<GeminiResponse>();
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<string> 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<string?> FindBestDbItemAsync(string userMessage, string? conversationSummary, Dictionary<string, string> 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<GeminiResponse>();
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<string> GetWebsiteNewsAsync(string url, int cantidad)
{
@@ -462,7 +470,7 @@ namespace ChatbotApi.Controllers
return textoDecodificado;
}
private async Task<Dictionary<string, string>> GetKnowledgeAsync()
private async Task<Dictionary<string, ContextoItem>> 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<AppContexto>();
// 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<string, string>();
}) ?? new Dictionary<string, ContextoItem>();
}
private async Task<string?> GetArticleContentAsync(string url)
@@ -514,5 +523,28 @@ namespace ChatbotApi.Controllers
return null;
}
}
private async Task<string> 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 <p>, <h2>, <li>, etc.
// pero para empezar, InnerText es un buen punto de partida.
return WebUtility.HtmlDecode(mainContentNode.InnerText);
}) ?? string.Empty;
}
}
}

View File

@@ -9,5 +9,6 @@ namespace ChatbotApi.Data.Models
public DbSet<ContextoItem> ContextoItems { get; set; } = null!;
public DbSet<ConversacionLog> ConversacionLogs { get; set; } = null!;
public DbSet<FuenteContexto> FuentesDeContexto { get; set; } = null!;
}
}

View File

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

View File

@@ -0,0 +1,364 @@
// <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("20251121141306_AddFuentesDeContextoTable")]
partial class AddFuentesDeContextoTable
{
/// <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>("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,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ChatbotApi.Migrations
{
/// <inheritdoc />
public partial class AddFuentesDeContextoTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FuentesDeContexto",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Nombre = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Url = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
DescripcionParaIA = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Activo = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FuentesDeContexto", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FuentesDeContexto");
}
}
}

View File

@@ -76,6 +76,37 @@ namespace ChatbotApi.Migrations
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>("Url")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.HasKey("Id");
b.ToTable("FuentesDeContexto");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")

View File

@@ -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<AdminPanelProps> = ({ onLogout }) => {
const [currentTab, setCurrentTab] = useState(0);
@@ -33,12 +32,13 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ onLogout }) => {
<Tabs value={currentTab} onChange={handleTabChange} textColor="inherit" indicatorColor="secondary">
<Tab label="Gestor de Contexto" />
<Tab label="Historial de Conversaciones" />
<Tab label="Gestor de Fuentes" />
</Tabs>
</AppBar>
{/* Mostramos el componente correspondiente a la pestaña activa */}
{currentTab === 0 && <ContextManager onAuthError={onLogout} />}
{currentTab === 1 && <LogsViewer onAuthError={onLogout} />}
{currentTab === 2 && <SourceManager onAuthError={onLogout} />}
</Box>
);
};

View File

@@ -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<SourceManagerProps> = ({ onAuthError }) => {
const [rows, setRows] = useState<FuenteContexto[]>([]);
const [open, setOpen] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [currentRow, setCurrentRow] = useState<Partial<FuenteContexto>>({});
const [error, setError] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<number | null>(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) => (
<Chip label={params.value ? 'Sí' : 'No'} color={params.value ? 'success' : 'default'} />
),
},
{
field: 'actions',
type: 'actions',
width: 100,
getActions: (params) => [
<GridActionsCellItem
icon={<EditIcon />}
label="Editar"
onClick={() => handleOpen(params.row as FuenteContexto)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Eliminar"
onClick={() => handleDeleteClick(params.id as number)}
/>,
],
},
];
return (
<Box sx={{ p: 4 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Button startIcon={<AddIcon />} variant="contained" onClick={() => handleOpen()} sx={{ mb: 2 }}>
Añadir Nueva Fuente
</Button>
<Box sx={{ height: 600, width: '100%' }}>
<DataGrid rows={rows} columns={columns} pageSizeOptions={[10, 25, 50]} />
</Box>
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="md">
<DialogTitle>{isEdit ? 'Editar Fuente' : 'Añadir Nueva Fuente'}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Nombre"
fullWidth
value={currentRow.nombre || ''}
onChange={(e) => setCurrentRow({ ...currentRow, nombre: e.target.value })}
helperText="Un nombre corto y descriptivo (ej: FAQs de Suscripción)."
/>
<TextField
margin="dense"
label="URL"
fullWidth
value={currentRow.url || ''}
onChange={(e) => setCurrentRow({ ...currentRow, url: e.target.value })}
helperText="La URL completa de la página que el bot debe leer."
/>
<TextField
margin="dense"
label="Descripción para la IA"
fullWidth
multiline
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'."
/>
<FormControlLabel
control={
<Switch
checked={currentRow.activo ?? true}
onChange={(e) => setCurrentRow({ ...currentRow, activo: e.target.checked })}
/>
}
label="Fuente activa"
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancelar</Button>
<Button onClick={handleSave} variant="contained">Guardar</Button>
</DialogActions>
</Dialog>
<Dialog
open={confirmOpen}
onClose={handleConfirmClose}
>
<DialogTitle>Confirmar Eliminación</DialogTitle>
<DialogContent>
<DialogContent>
¿Estás seguro de que quieres eliminar este item? Esta acción no se puede deshacer.
</DialogContent>
</DialogContent>
<DialogActions>
<Button onClick={handleConfirmClose}>Cancelar</Button>
<Button onClick={handleConfirmDelete} color="error" variant="contained">
Eliminar
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default SourceManager;