Fix Contexto Hilo Conversación

This commit is contained in:
2025-11-21 12:10:45 -03:00
parent 2e353cbb8c
commit 01783a52cc
2 changed files with 65 additions and 97 deletions

View File

@@ -1,6 +1,5 @@
// ChatbotApi/Controllers/ChatController.cs
using Microsoft.AspNetCore.Mvc;
using ChatbotApi.Models;
using ChatbotApi.Data.Models;
using System.Net;
using System.Text;
@@ -10,6 +9,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
// Clases de Request/Response
public class GenerationConfig
@@ -33,7 +33,7 @@ public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[
public class Candidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
public class GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { get; set; } = default!; }
public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
public enum IntentType { Article, Database, Homepage, ExternalSource }
public enum IntentType { Article, KnowledgeBase, Homepage }
namespace ChatbotApi.Controllers
{
@@ -47,6 +47,7 @@ namespace ChatbotApi.Controllers
private readonly ILogger<ChatController> _logger;
private static readonly HttpClient _httpClient = new HttpClient();
private static readonly string _knowledgeCacheKey = "KnowledgeBase";
private static readonly string _fuentesCacheKey = "FuentesDeContexto";
private static readonly string _siteUrl = "https://www.eldia.com/";
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
@@ -57,9 +58,7 @@ namespace ChatbotApi.Controllers
_logger = logger;
_cache = memoryCache;
_serviceProvider = serviceProvider;
var apiKey = configuration["Gemini:GeminiApiKey"]
?? throw new InvalidOperationException("La API Key de Gemini no está configurada en .env");
var apiKey = configuration["Gemini:GeminiApiKey"] ?? throw new InvalidOperationException("La API Key de Gemini no está configurada en .env");
var baseUrl = configuration["Gemini:GeminiApiUrl"];
_apiUrl = $"{baseUrl}{apiKey}";
}
@@ -102,34 +101,14 @@ namespace ChatbotApi.Controllers
}
}
private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary<string, ContextoItem> knowledgeBase)
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary)
{
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones...");
promptBuilder.AppendLine("- [ARTICULO_ACTUAL]");
promptBuilder.AppendLine("- [NOTICIAS_PORTADA]");
promptBuilder.AppendLine("- [BASE_DE_DATOS:CLAVE_SELECCIONADA]");
// --- LÓGICA DINÁMICA ---
List<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 sobre el artículo que se está discutiendo.");
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales o eventos.");
promptBuilder.AppendLine("[BASE_DE_DATOS:CLAVE_SELECCIONADA]: Úsala para preguntas sobre información específica del diario. DEBES reemplazar 'CLAVE_SELECCIONADA' con la clave más relevante de la siguiente lista:");
var dbKeys = string.Join(", ", knowledgeBase.Keys);
promptBuilder.AppendLine($" - Claves disponibles: {dbKeys}");
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones. Basado en la conversación y la pregunta del usuario, elige la categoría de información necesaria. Responde única y exclusivamente con una de las siguientes tres opciones: [ARTICULO_ACTUAL], [BASE_DE_CONOCIMIENTO], [NOTICIAS_PORTADA].");
promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE CATEGORÍAS ---");
promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Si la pregunta es una continuación directa sobre el artículo que se está discutiendo.");
promptBuilder.AppendLine("[BASE_DE_CONOCIMIENTO]: Si la pregunta es sobre información general del diario (contacto, registro, suscripciones, preguntas frecuentes, etc.).");
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Para preguntas sobre noticias de último momento o eventos actuales.");
if (!string.IsNullOrWhiteSpace(conversationSummary))
{
@@ -145,7 +124,7 @@ namespace ChatbotApi.Controllers
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
promptBuilder.AppendLine(userMessage);
promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---");
promptBuilder.AppendLine("\n--- CATEGORÍA SELECCIONADA ---");
var finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
@@ -154,36 +133,24 @@ namespace ChatbotApi.Controllers
try
{
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
if (!response.IsSuccessStatusCode) return (IntentType.Homepage, null);
if (!response.IsSuccessStatusCode) return IntentType.Homepage;
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
_logger.LogInformation("Intención y datos detectados: {Response}", responseText);
_logger.LogInformation("Intención detectada: {Intent}", responseText);
if (responseText.Contains("ARTICULO_ACTUAL")) return (IntentType.Article, null);
if (responseText.Contains("NOTICIAS_PORTADA")) return (IntentType.Homepage, null);
if (responseText.Contains("BASE_DE_DATOS:"))
{
var key = responseText.Split(new[] { ':' }, 2)[1].TrimEnd(']');
return (IntentType.Database, key);
}
if (responseText.Contains("FUENTE_EXTERNA:"))
{
var url = responseText.Split(new[] { ':' }, 2, StringSplitOptions.None)[1].TrimEnd(']');
return (IntentType.ExternalSource, url); // Necesitaremos un nuevo IntentType
}
return (IntentType.Homepage, null);
if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article;
if (responseText.Contains("BASE_DE_CONOCIMIENTO")) return IntentType.KnowledgeBase;
return IntentType.Homepage;
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
return (IntentType.Homepage, null);
return IntentType.Homepage;
}
}
[HttpPost("stream-message")]
[EnableRateLimiting("fixed")]
public async IAsyncEnumerable<string> StreamMessage(
@@ -210,11 +177,7 @@ namespace ChatbotApi.Controllers
articleContext = await GetArticleContentAsync(request.ContextUrl);
}
var knowledgeBase = await GetKnowledgeAsync();
// --- CORRECCIÓN 2: El código que llama a GetIntentAsync debe esperar una tupla ---
var (detectedIntent, intentData) = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary, knowledgeBase);
intent = detectedIntent;
intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary);
switch (intent)
{
@@ -224,24 +187,27 @@ namespace ChatbotApi.Controllers
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto completo de una noticia.";
break;
case IntentType.Database:
// --- CORRECCIÓN 3: La lógica aquí debe manejar la clave recibida ---
_logger.LogInformation("Ejecutando intención: Base de Datos con clave '{Key}'.", intentData);
if (intentData != null && knowledgeBase.TryGetValue(intentData, out var dbItem))
case IntentType.KnowledgeBase:
_logger.LogInformation("Ejecutando intención: Base de Conocimiento Unificada.");
var contextBuilder = new StringBuilder();
contextBuilder.AppendLine("Usa la siguiente base de conocimiento para responder la pregunta del usuario:");
var knowledgeBaseItems = await GetKnowledgeItemsAsync();
foreach (var item in knowledgeBaseItems.Values)
{
// Ahora dbItem es un objeto ContextoItem, no un string.
var dbContextBuilder = new StringBuilder();
dbContextBuilder.AppendLine("Aquí tienes la información solicitada:");
dbContextBuilder.AppendLine($"- PREGUNTA: {dbItem.Descripcion}");
dbContextBuilder.AppendLine($" RESPUESTA: {dbItem.Valor}");
context = dbContextBuilder.ToString();
contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}");
}
else
var fuentesExternas = await GetFuentesDeContextoAsync();
foreach (var fuente in fuentesExternas)
{
context = "No se encontró información relevante para la clave solicitada.";
_logger.LogWarning("La clave '{Key}' devuelta por la IA no es válida.", intentData);
contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---");
string scrapedContent = await ScrapeUrlContentAsync(fuente.Url);
contextBuilder.AppendLine(scrapedContent);
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene la pregunta y respuesta encontrada.";
context = contextBuilder.ToString();
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en la 'BASE DE CONOCIMIENTO' proporcionada.";
break;
case IntentType.Homepage:
@@ -250,14 +216,6 @@ namespace ChatbotApi.Controllers
context = await GetWebsiteNewsAsync(_siteUrl, 25);
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene una lista de noticias de portada.";
break;
case IntentType.ExternalSource:
_logger.LogInformation("Ejecutando intención: Fuente Externa con URL '{Url}'.", intentData);
if (!string.IsNullOrEmpty(intentData))
{
context = await ScrapeUrlContentAsync(intentData);
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto de una página web.";
break;
}
}
catch (Exception ex)
@@ -394,8 +352,6 @@ namespace ChatbotApi.Controllers
}
}
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
{
try
@@ -470,25 +426,34 @@ namespace ChatbotApi.Controllers
return textoDecodificado;
}
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeAsync()
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeItemsAsync()
{
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry =>
{
_logger.LogInformation("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos...");
_logger.LogInformation("Cargando ContextoItems desde la base de datos a la caché...");
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
// Usamos ToDictionaryAsync para obtener el objeto ContextoItem completo.
var knowledge = await dbContext.ContextoItems
.AsNoTracking()
.ToDictionaryAsync(item => item.Clave, item => item);
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
return knowledge;
return await dbContext.ContextoItems.AsNoTracking().ToDictionaryAsync(item => item.Clave, item => item);
}
}) ?? new Dictionary<string, ContextoItem>();
}
private async Task<List<FuenteContexto>> GetFuentesDeContextoAsync()
{
return await _cache.GetOrCreateAsync(_fuentesCacheKey, async entry =>
{
_logger.LogInformation("Cargando FuentesDeContexto desde la base de datos a la caché...");
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
return await dbContext.FuentesDeContexto.Where(f => f.Activo).AsNoTracking().ToListAsync();
}
}) ?? new List<FuenteContexto>();
}
private async Task<string?> GetArticleContentAsync(string url)
{
try
@@ -526,24 +491,27 @@ namespace ChatbotApi.Controllers
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 =>
return await _cache.GetOrCreateAsync($"scrape_{url}", async entry =>
{
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Cachear por 1 hora
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url);
// Selector genérico que intenta obtener el contenido principal
// Esto puede necesitar ajustes dependiendo de la estructura de las páginas
var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
if (mainContentNode == null) return string.Empty;
// Podríamos hacer esto mucho más inteligente, buscando <p>, <h2>, <li>, etc.
// pero para empezar, InnerText es un buen punto de partida.
return WebUtility.HtmlDecode(mainContentNode.InnerText);
// Extraer texto de etiquetas comunes de contenido
var textNodes = mainContentNode.SelectNodes(".//p | .//h1 | .//h2 | .//h3 | .//li");
if (textNodes == null) return WebUtility.HtmlDecode(mainContentNode.InnerText);
var sb = new StringBuilder();
foreach (var node in textNodes)
{
sb.AppendLine(WebUtility.HtmlDecode(node.InnerText).Trim());
}
return sb.ToString();
}) ?? string.Empty;
}
}

View File

@@ -167,7 +167,7 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
rows={3}
value={currentRow.descripcionParaIA || ''}
onChange={(e) => setCurrentRow({ ...currentRow, descripcionParaIA: e.target.value })}
helperText="¡Crucial! Describe en una frase para qué sirve esta fuente. Ej: 'Usar para responder preguntas sobre cómo registrarse, iniciar sesión o por qué es obligatorio el registro'."
helperText="¡Crucial! Describe en una frase para qué sirve esta fuente. Ej: 'Usar para responder preguntas sobre cómo registrarse, iniciar sesión o sobre el registro'."
/>
<FormControlLabel
control={