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 // ChatbotApi/Controllers/ChatController.cs
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ChatbotApi.Models;
using ChatbotApi.Data.Models; using ChatbotApi.Data.Models;
using System.Net; using System.Net;
using System.Text; using System.Text;
@@ -10,6 +9,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
// Clases de Request/Response // Clases de Request/Response
public class GenerationConfig 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 Candidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
public class GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { 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 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 namespace ChatbotApi.Controllers
{ {
@@ -47,6 +47,7 @@ namespace ChatbotApi.Controllers
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 _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. " };
@@ -57,9 +58,7 @@ namespace ChatbotApi.Controllers
_logger = logger; _logger = logger;
_cache = memoryCache; _cache = memoryCache;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
var apiKey = configuration["Gemini:GeminiApiKey"] var apiKey = configuration["Gemini:GeminiApiKey"] ?? throw new InvalidOperationException("La API Key de Gemini no está configurada en .env");
?? throw new InvalidOperationException("La API Key de Gemini no está configurada en .env");
var baseUrl = configuration["Gemini:GeminiApiUrl"]; var baseUrl = configuration["Gemini:GeminiApiUrl"];
_apiUrl = $"{baseUrl}{apiKey}"; _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(); var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un router de intenciones..."); 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("- [ARTICULO_ACTUAL]"); promptBuilder.AppendLine("\n--- DESCRIPCIÓN DE CATEGORÍAS ---");
promptBuilder.AppendLine("- [NOTICIAS_PORTADA]"); promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Si la pregunta es una continuación directa sobre el artículo que se está discutiendo.");
promptBuilder.AppendLine("- [BASE_DE_DATOS:CLAVE_SELECCIONADA]"); 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.");
// --- 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}");
if (!string.IsNullOrWhiteSpace(conversationSummary)) if (!string.IsNullOrWhiteSpace(conversationSummary))
{ {
@@ -145,7 +124,7 @@ namespace ChatbotApi.Controllers
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---"); promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
promptBuilder.AppendLine(userMessage); promptBuilder.AppendLine(userMessage);
promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---"); promptBuilder.AppendLine("\n--- CATEGORÍA SELECCIONADA ---");
var finalPrompt = promptBuilder.ToString(); var finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } }; var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
@@ -154,36 +133,24 @@ namespace ChatbotApi.Controllers
try try
{ {
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData); 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 geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? ""; 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("ARTICULO_ACTUAL")) return IntentType.Article;
if (responseText.Contains("NOTICIAS_PORTADA")) return (IntentType.Homepage, null); if (responseText.Contains("BASE_DE_CONOCIMIENTO")) return IntentType.KnowledgeBase;
if (responseText.Contains("BASE_DE_DATOS:")) return IntentType.Homepage;
{
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) catch (Exception ex)
{ {
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage."); _logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
return (IntentType.Homepage, null); return IntentType.Homepage;
} }
} }
[HttpPost("stream-message")] [HttpPost("stream-message")]
[EnableRateLimiting("fixed")] [EnableRateLimiting("fixed")]
public async IAsyncEnumerable<string> StreamMessage( public async IAsyncEnumerable<string> StreamMessage(
@@ -210,11 +177,7 @@ namespace ChatbotApi.Controllers
articleContext = await GetArticleContentAsync(request.ContextUrl); articleContext = await GetArticleContentAsync(request.ContextUrl);
} }
var knowledgeBase = await GetKnowledgeAsync(); intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary);
// --- 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) 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."; 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; break;
case IntentType.Database: case IntentType.KnowledgeBase:
// --- CORRECCIÓN 3: La lógica aquí debe manejar la clave recibida --- _logger.LogInformation("Ejecutando intención: Base de Conocimiento Unificada.");
_logger.LogInformation("Ejecutando intención: Base de Datos con clave '{Key}'.", intentData); var contextBuilder = new StringBuilder();
if (intentData != null && knowledgeBase.TryGetValue(intentData, out var dbItem)) 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. contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}");
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
var fuentesExternas = await GetFuentesDeContextoAsync();
foreach (var fuente in fuentesExternas)
{ {
context = "No se encontró información relevante para la clave solicitada."; contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---");
_logger.LogWarning("La clave '{Key}' devuelta por la IA no es válida.", intentData); 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; break;
case IntentType.Homepage: case IntentType.Homepage:
@@ -250,14 +216,6 @@ namespace ChatbotApi.Controllers
context = await GetWebsiteNewsAsync(_siteUrl, 25); 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."; 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; 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) catch (Exception ex)
@@ -394,8 +352,6 @@ namespace ChatbotApi.Controllers
} }
} }
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad) private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
{ {
try try
@@ -470,25 +426,34 @@ namespace ChatbotApi.Controllers
return textoDecodificado; return textoDecodificado;
} }
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeAsync() private async Task<Dictionary<string, ContextoItem>> GetKnowledgeItemsAsync()
{ {
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry => 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); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
using (var scope = _serviceProvider.CreateScope()) using (var scope = _serviceProvider.CreateScope())
{ {
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>(); var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
// Usamos ToDictionaryAsync para obtener el objeto ContextoItem completo. return await dbContext.ContextoItems.AsNoTracking().ToDictionaryAsync(item => item.Clave, item => item);
var knowledge = await dbContext.ContextoItems
.AsNoTracking()
.ToDictionaryAsync(item => item.Clave, item => item);
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
return knowledge;
} }
}) ?? new Dictionary<string, ContextoItem>(); }) ?? 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) private async Task<string?> GetArticleContentAsync(string url)
{ {
try try
@@ -526,24 +491,27 @@ namespace ChatbotApi.Controllers
private async Task<string> ScrapeUrlContentAsync(string url) 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($"scrape_{url}", async entry =>
return await _cache.GetOrCreateAsync(url, async entry =>
{ {
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url); _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 web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url); 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"); var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
if (mainContentNode == null) return string.Empty; if (mainContentNode == null) return string.Empty;
// Podríamos hacer esto mucho más inteligente, buscando <p>, <h2>, <li>, etc. // Extraer texto de etiquetas comunes de contenido
// pero para empezar, InnerText es un buen punto de partida. var textNodes = mainContentNode.SelectNodes(".//p | .//h1 | .//h2 | .//h3 | .//li");
return WebUtility.HtmlDecode(mainContentNode.InnerText); 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; }) ?? string.Empty;
} }
} }

View File

@@ -167,7 +167,7 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
rows={3} rows={3}
value={currentRow.descripcionParaIA || ''} value={currentRow.descripcionParaIA || ''}
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 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 <FormControlLabel
control={ control={