Fix Contexto Hilo Conversación
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user