Fix: Detección de Contexto Según Tema

This commit is contained in:
2025-11-20 10:52:46 -03:00
parent 83a48e16da
commit c94936d56e
4 changed files with 246 additions and 213 deletions

View File

@@ -19,6 +19,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 }
namespace ChatbotApi.Controllers namespace ChatbotApi.Controllers
{ {
@@ -28,10 +29,10 @@ namespace ChatbotApi.Controllers
{ {
private readonly string _apiUrl; private readonly string _apiUrl;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly IServiceProvider _serviceProvider; // Para crear un scope de DB 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"; // Clave única para nuestra caché private static readonly string _knowledgeCacheKey = "KnowledgeBase";
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. " };
@@ -47,14 +48,57 @@ namespace ChatbotApi.Controllers
var baseUrl = configuration["Gemini:GeminiApiUrl"]; var baseUrl = configuration["Gemini:GeminiApiUrl"];
_apiUrl = $"{baseUrl}{apiKey}"; _apiUrl = $"{baseUrl}{apiKey}";
} }
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent)
{
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("\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.");
if (!string.IsNullOrEmpty(activeArticleContent))
{
promptBuilder.AppendLine("\n--- CONVERSACIÓN ACTUAL (Contexto del artículo) ---");
promptBuilder.AppendLine(new string(activeArticleContent.Take(500).ToArray()) + "...");
}
promptBuilder.AppendLine("\n--- PREGUNTA DEL USUARIO ---");
promptBuilder.AppendLine(userMessage);
promptBuilder.AppendLine("\n--- HERRAMIENTA SELECCIONADA ---");
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 IntentType.Homepage;
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
_logger.LogInformation("Intención detectada: {Intent}", responseText);
if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article;
if (responseText.Contains("BASE_DE_DATOS")) return IntentType.Database;
return IntentType.Homepage;
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
return IntentType.Homepage;
}
}
[HttpPost("stream-message")] [HttpPost("stream-message")]
[EnableRateLimiting("fixed")] [EnableRateLimiting("fixed")]
public async IAsyncEnumerable<string> StreamMessage( public async IAsyncEnumerable<string> StreamMessage(
[FromBody] ChatRequest request, [FromBody] ChatRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
// --- FASE 1: Validación y Preparación ---
if (string.IsNullOrWhiteSpace(request?.Message)) if (string.IsNullOrWhiteSpace(request?.Message))
{ {
yield return "Error: No he recibido ningún mensaje."; yield return "Error: No he recibido ningún mensaje.";
@@ -62,44 +106,64 @@ namespace ChatbotApi.Controllers
} }
string userMessage = request.Message; string userMessage = request.Message;
string lowerUserMessage = userMessage.ToLowerInvariant(); string context = "";
string context; string promptInstructions = "";
string? errorMessage = null; // Variable para almacenar el mensaje de error string? articleContext = null;
string? errorMessage = null;
try try
{ {
var knowledgeBase = await GetKnowledgeAsync(); if (!string.IsNullOrEmpty(request.ContextUrl))
string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase); {
context = dbContext ?? await GetWebsiteNewsAsync(_siteUrl, 15); articleContext = await GetArticleContentAsync(request.ContextUrl);
}
IntentType intent = await GetIntentAsync(userMessage, articleContext);
switch (intent)
{
case IntentType.Article:
_logger.LogInformation("Ejecutando intención: Artículo Actual.");
context = articleContext ?? "No se pudo cargar el artículo.";
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:
_logger.LogInformation("Ejecutando intención: Base de Datos.");
var knowledgeBase = await GetKnowledgeAsync();
context = await FindBestDbItemAsync(userMessage, 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.).";
break;
case IntentType.Homepage:
default:
_logger.LogInformation("Ejecutando intención: Noticias de Portada.");
context = await GetWebsiteNewsAsync(_siteUrl, 15);
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)'.";
break;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error al obtener el contexto para el streaming."); _logger.LogError(ex, "Error al procesar la intención y el contexto.");
errorMessage = "Error: No se pudo obtener la información de contexto."; errorMessage = "Error: Lo siento, estoy teniendo un problema técnico al procesar tu pregunta.";
context = string.Empty; // Aseguramos que el contexto no sea nulo context = string.Empty;
promptInstructions = string.Empty;
} }
// Si hubo un error en la fase anterior, lo devolvemos
if (!string.IsNullOrEmpty(errorMessage)) if (!string.IsNullOrEmpty(errorMessage))
{ {
yield return errorMessage; yield return errorMessage;
yield break; yield break;
} }
if (string.IsNullOrWhiteSpace(context))
{
yield return "Error: No pude obtener información para responder a tu pregunta.";
yield break;
}
// --- FASE 2: Configuración de la Conexión ---
Stream? responseStream = null; Stream? responseStream = null;
try try
{ {
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."); promptBuilder.AppendLine("Eres DiaBot, el asistente virtual del periódico El Día. Tu personalidad es profesional, servicial y concisa.");
promptBuilder.AppendLine(GetContextFromDb(lowerUserMessage, await GetKnowledgeAsync()) != null ? "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.). No hables de noticias." : "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO DEL SITIO WEB' que contiene una lista de noticias. Si el usuario pide la URL de una noticia, DEBES proporcionarla en formato Markdown: '[texto del enlace](URL)'."); promptBuilder.AppendLine(promptInstructions);
promptBuilder.AppendLine("NUNCA INVENTES información. Si la respuesta no está en el contexto, indica amablemente que no encontraste la información."); promptBuilder.AppendLine("NUNCA INVENTES información. Si la respuesta no está en el contexto, indica amablemente que no encontraste la información.");
promptBuilder.AppendLine("\nCONTEXTO:\n---"); promptBuilder.AppendLine("\nCONTEXTO:\n---");
promptBuilder.AppendLine(context); promptBuilder.AppendLine(context);
@@ -135,14 +199,12 @@ namespace ChatbotApi.Controllers
errorMessage = "Error: Lo siento, estoy teniendo un problema técnico."; errorMessage = "Error: Lo siento, estoy teniendo un problema técnico.";
} }
// Devolvemos el error de la fase de conexión si ocurrió
if (!string.IsNullOrEmpty(errorMessage)) if (!string.IsNullOrEmpty(errorMessage))
{ {
yield return errorMessage; yield return errorMessage;
yield break; yield break;
} }
// --- FASE 3: Lectura y Devolución del Stream ---
var fullBotReply = new StringBuilder(); var fullBotReply = new StringBuilder();
if (responseStream != null) if (responseStream != null)
{ {
@@ -155,22 +217,19 @@ namespace ChatbotApi.Controllers
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue; if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue;
var jsonString = line.Substring(6); var jsonString = line.Substring(6);
string? chunk = null; // 1. Declaramos la variable 'chunk' fuera del try. string? chunk = null;
try try
{ {
// 2. El bloque try solo se encarga de la deserialización.
var geminiResponse = JsonSerializer.Deserialize<GeminiStreamingResponse>(jsonString); var geminiResponse = JsonSerializer.Deserialize<GeminiStreamingResponse>(jsonString);
chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text; chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text;
} }
catch (JsonException ex) catch (JsonException ex)
{ {
// 3. Si falla, lo registramos y pasamos al siguiente fragmento.
_logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString); _logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString);
continue; continue;
} }
// 4. El yield return ahora está fuera del bloque try-catch.
if (chunk != null) if (chunk != null)
{ {
fullBotReply.Append(chunk); fullBotReply.Append(chunk);
@@ -180,7 +239,6 @@ namespace ChatbotApi.Controllers
} }
} }
// --- FASE 4: Guardado final ---
if (fullBotReply.Length > 0) if (fullBotReply.Length > 0)
{ {
await SaveConversationLogAsync(userMessage, fullBotReply.ToString()); await SaveConversationLogAsync(userMessage, fullBotReply.ToString());
@@ -209,180 +267,100 @@ namespace ChatbotApi.Controllers
_logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming."); _logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming.");
} }
} }
[HttpPost("message")] private async Task<string?> FindBestDbItemAsync(string userMessage, Dictionary<string, string> knowledgeBase)
[EnableRateLimiting("fixed")]
public async Task<IActionResult> PostMessage([FromBody] ChatRequest request)
{ {
if (string.IsNullOrWhiteSpace(request?.Message)) if (knowledgeBase == null || !knowledgeBase.Any()) return null;
{
return BadRequest(new ChatResponse { Reply = "No he recibido ningún mensaje." }); var availableKeys = string.Join(", ", knowledgeBase.Keys);
}
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un buscador semántico. Basado en la PREGUNTA DEL USUARIO, elige la CLAVE más relevante de la LISTA DE CLAVES DISPONIBLES. Responde única y exclusivamente con la clave que elijas.");
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 try
{ {
string userMessage = request.Message; var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
string lowerUserMessage = userMessage.ToLowerInvariant(); if (!response.IsSuccessStatusCode) return null;
string context;
string promptInstructions;
// 1. Obtenemos el conocimiento desde nuestro nuevo método de caché
var knowledgeBase = await GetKnowledgeAsync();
string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase);
if (dbContext != null)
{
context = dbContext;
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.). No hables de noticias.";
}
// 2. Si no encontramos nada en la base de conocimiento, buscamos en las noticias.
else
{
context = await GetWebsiteNewsAsync(_siteUrl, 15);
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO DEL SITIO WEB' que contiene una lista de noticias. Si el usuario pide la URL de una noticia, DEBES proporcionarla en formato Markdown: '[texto del enlace](URL)'.";
}
if (string.IsNullOrWhiteSpace(context))
{
return StatusCode(500, new ChatResponse { Reply = "No pude obtener información para responder a tu pregunta." });
}
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("INSTRUCCIONES:");
promptBuilder.AppendLine("Eres DiaBot, el asistente virtual del periódico El Día. Tu personalidad es profesional, servicial y concisa. Debes hablar en español 'Rioplatense'.");
promptBuilder.AppendLine(promptInstructions); // Instrucciones dinámicas
promptBuilder.AppendLine("NUNCA INVENTES información. Si la respuesta no está en el contexto, indica amablemente que no encontraste la información.");
promptBuilder.AppendLine("\nCONTEXTO:\n---");
promptBuilder.AppendLine(context);
promptBuilder.AppendLine("---\n\nPREGUNTA DEL USUARIO:\n---");
promptBuilder.AppendLine(userMessage);
promptBuilder.AppendLine("---\n\nRESPUESTA:");
string finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
var response = await _httpClient.PostAsJsonAsync(_apiUrl, requestData);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("La API de Gemini devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent);
return StatusCode(500, new ChatResponse { Reply = "Hubo un error al comunicarse con el asistente de IA." });
}
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>(); var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
string botReply = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "Lo siento, no pude procesar una respuesta."; var bestKey = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
_logger.LogInformation($"[DEBUG] Respuesta de Gemini: '{botReply}'"); if (bestKey != null && knowledgeBase.TryGetValue(bestKey, out var contextValue))
try
{ {
// Usamos el IServiceProvider para crear un scope de DbContext temporal y seguro. _logger.LogInformation("Búsqueda en DB por IA: La pregunta '{userMessage}' se asoció con la clave '{bestKey}'.", userMessage, bestKey);
using (var scope = _serviceProvider.CreateScope()) return contextValue;
{
// Renombramos la variable para evitar conflictos de ámbito.
var scopedDbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
var logEntry = new ConversacionLog
{
UsuarioMensaje = userMessage,
BotRespuesta = botReply,
Fecha = DateTime.UtcNow
};
scopedDbContext.ConversacionLogs.Add(logEntry);
await scopedDbContext.SaveChangesAsync();
}
} }
catch (Exception logEx)
{ _logger.LogWarning("Búsqueda en DB por IA: La clave devuelta '{bestKey}' no es válida.", bestKey);
_logger.LogError(logEx, "Error al intentar guardar el log de la conversación en la base de datos."); return null;
}
return Ok(new ChatResponse { Reply = botReply });
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error inesperado al procesar el mensaje del usuario."); _logger.LogError(ex, "Excepción en FindBestDbItemAsync.");
return StatusCode(500, new ChatResponse { Reply = "Lo siento, estoy teniendo un problema técnico." }); return null;
} }
} }
// Método para buscar contexto en la caché de la DB
private string? GetContextFromDb(string lowerUserMessage, Dictionary<string, string> knowledgeBase)
{
// 1. Definimos una lista de palabras comunes a ignorar para hacer la búsqueda más precisa.
var stopWords = new HashSet<string>
{
"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "a", "ante",
"con", "contra", "desde", "en", "entre", "hacia", "hasta", "para", "por", "segun",
"sin", "sobre", "tras", "y", "o", "que", "cual", "cuales", "como", "cuando", "donde",
"quien", "es", "soy", "estoy", "mi", "mis", "quiero", "necesito", "saber", "dime", "dame",
"informacion", "acerca", "mas"
};
// 2. Separamos el mensaje del usuario en palabras individuales, eliminando las stop words.
var userWords = lowerUserMessage
.Split(new[] { ' ', ',', '.', '?', '!', '¿', '¡' }, StringSplitOptions.RemoveEmptyEntries)
.Where(word => !stopWords.Contains(word))
.ToHashSet(); // Usamos un HashSet para búsquedas de palabras muy rápidas.
if (!userWords.Any())
{
return null; // Si solo había stop words, no buscamos nada.
}
// 3. Iteramos sobre la base de conocimiento para encontrar la mejor coincidencia.
foreach (var kvp in knowledgeBase)
{
// Separamos las claves compuestas ("contacto_telefono" -> ["contacto", "telefono"])
var keywords = kvp.Key.Split('_');
// 4. Comprobamos si ALGUNA de las palabras clave de la BD coincide con ALGUNA de las palabras del usuario.
if (keywords.Any(k => userWords.Contains(k)))
{
_logger.LogInformation("Contexto encontrado por coincidencia de palabra clave. Clave de BD: '{DbKey}', Palabra de usuario encontrada: '{MatchedWord}'",
kvp.Key,
string.Join(", ", keywords.Where(k => userWords.Contains(k))));
return kvp.Value; // Devolvemos el valor correspondiente a la primera clave que coincida.
}
}
return null; // No se encontró ninguna coincidencia.
}
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad) private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
{ {
try try
{ {
var web = new HtmlWeb(); var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url); var doc = await web.LoadFromWebAsync(url);
var nodosDeEnlace = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]/a[@href]");
if (nodosDeEnlace == null || !nodosDeEnlace.Any()) return string.Empty; var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
if (articleNodes == null || !articleNodes.Any())
{
_logger.LogWarning("No se encontraron nodos de <article> en la URL {Url}", url);
return string.Empty;
}
var contextBuilder = new StringBuilder(); var contextBuilder = new StringBuilder();
contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:"); contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
var urlsProcesadas = new HashSet<string>(); var urlsProcesadas = new HashSet<string>();
int count = 0; int count = 0;
foreach (var nodoEnlace in nodosDeEnlace) foreach (var articleNode in articleNodes)
{ {
var urlRelativa = nodoEnlace.GetAttributeValue("href", string.Empty); var linkNode = articleNode.SelectSingleNode(".//a[@href]");
if (string.IsNullOrEmpty(urlRelativa) || urlRelativa == "#" || urlsProcesadas.Contains(urlRelativa)) continue; var titleNode = articleNode.SelectSingleNode(".//h2");
var nodoTitulo = nodoEnlace.SelectSingleNode(".//h1 | .//h2"); if (linkNode != null && titleNode != null)
if (nodoTitulo != null)
{ {
var textoLimpio = CleanTitleText(nodoTitulo.InnerText); var relativeUrl = linkNode.GetAttributeValue("href", string.Empty);
var urlCompleta = urlRelativa.StartsWith("/") ? new Uri(new Uri(url), urlRelativa).ToString() : urlRelativa;
contextBuilder.AppendLine($"- Título: \"{textoLimpio}\", URL: {urlCompleta}"); if (string.IsNullOrEmpty(relativeUrl) || relativeUrl == "#" || urlsProcesadas.Contains(relativeUrl))
urlsProcesadas.Add(urlRelativa); {
continue;
}
var cleanTitle = CleanTitleText(titleNode.InnerText);
var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
contextBuilder.AppendLine($"- Título: \"{cleanTitle}\", URL: {fullUrl}");
urlsProcesadas.Add(relativeUrl);
count++; count++;
} }
if (count >= cantidad) break;
if (count >= cantidad)
{
break;
}
} }
return contextBuilder.ToString();
var result = contextBuilder.ToString();
_logger.LogInformation("Scraping de la portada exitoso. Se encontraron {Count} noticias.", count);
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -405,32 +383,58 @@ namespace ChatbotApi.Controllers
} }
return textoDecodificado; return textoDecodificado;
} }
private async Task<Dictionary<string, string>> GetKnowledgeAsync() private async Task<Dictionary<string, string>> GetKnowledgeAsync()
{ {
// Intenta obtener el diccionario de la caché.
// Si no existe, el segundo argumento (la función factory) se ejecutará.
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("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos...");
// Establecemos un tiempo de expiración para la caché.
// Después de 5 minutos, la caché se considerará inválida y se recargará en la siguiente petición.
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
// Usamos IServiceProvider para crear un 'scope' de servicios temporal.
// Esto es necesario porque el DbContext tiene un tiempo de vida por petición (scoped),
// y esta función factory podría ejecutarse fuera de ese contexto.
using (var scope = _serviceProvider.CreateScope()) using (var scope = _serviceProvider.CreateScope())
{ {
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>(); var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
var knowledge = await dbContext.ContextoItems var knowledge = await dbContext.ContextoItems
.AsNoTracking() // Mejora el rendimiento para consultas de solo lectura .AsNoTracking()
.ToDictionaryAsync(item => item.Clave, item => item.Valor); .ToDictionaryAsync(item => item.Clave, item => item.Valor);
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items."); _logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
return knowledge; return knowledge;
} }
}) ?? new Dictionary<string, string>(); // Si todo falla, devuelve un diccionario vacío. }) ?? new Dictionary<string, string>();
}
private async Task<string?> GetArticleContentAsync(string url)
{
try
{
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url);
var paragraphs = doc.DocumentNode.SelectNodes("//div[contains(@class, 'cuerpo_nota')]//p");
if (paragraphs == null || !paragraphs.Any())
{
_logger.LogWarning("No se encontraron párrafos en la URL {Url} con el selector '//div[contains(@class, 'cuerpo_nota')]//p'.", url);
return null;
}
var articleText = new StringBuilder();
foreach (var p in paragraphs)
{
var cleanText = WebUtility.HtmlDecode(p.InnerText).Trim();
if (!string.IsNullOrWhiteSpace(cleanText))
{
articleText.AppendLine(cleanText);
}
}
_logger.LogInformation("Se extrajo con éxito el contenido del artículo de {Url}", url);
return articleText.ToString();
}
catch (Exception ex)
{
_logger.LogError(ex, "No se pudo descargar o procesar el contenido del artículo de la URL {Url}", url);
return null;
}
} }
} }
} }

View File

@@ -5,4 +5,5 @@ public class ChatRequest
[Required] [Required]
[MaxLength(200)] [MaxLength(200)]
public required string Message { get; set; } public required string Message { get; set; }
public string? ContextUrl { get; set; }
} }

View File

@@ -137,4 +137,17 @@ opacity: 0.8;
text-align: right; text-align: right;
margin-top: 4px; margin-top: 4px;
margin-right: 15px; margin-right: 15px;
}
.context-indicator {
padding: 5px 15px;
background-color: #e9e9eb;
font-size: 0.8rem;
color: #555;
text-align: center;
border-top: 1px solid #ccc;
}
.context-indicator span {
font-weight: bold;
} }

View File

@@ -34,6 +34,7 @@ const Chatbot: React.FC = () => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<null | HTMLDivElement>(null); const messagesEndRef = useRef<null | HTMLDivElement>(null);
const [activeArticleUrl, setActiveArticleUrl] = useState<string | null>(null);
// Añadimos un useEffect para guardar los mensajes. // Añadimos un useEffect para guardar los mensajes.
useEffect(() => { useEffect(() => {
@@ -78,10 +79,15 @@ const Chatbot: React.FC = () => {
setMessages(prev => [...prev, botMessagePlaceholder]); setMessages(prev => [...prev, botMessagePlaceholder]);
try { try {
const requestBody = {
message: messageToSend,
contextUrl: activeArticleUrl
};
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/chat/stream-message`, { const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/chat/stream-message`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: messageToSend }), body: JSON.stringify(requestBody),
}); });
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
@@ -90,50 +96,54 @@ const Chatbot: React.FC = () => {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let accumulatedResponse = ''; // Variable para acumular el texto crudo
const readStream = async () => { const readStream = async () => {
let fullReply = '';
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
// El stream ha terminado, no hacemos nada más aquí. let finalCleanText = '';
try {
const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']');
finalCleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply;
} catch (e) {
finalCleanText = fullReply.replace(/^\["|"]$|","/g, '');
}
const linkRegex = /\[.*?\]\((https?:\/\/[^\s]+)\)/;
const match = finalCleanText.match(linkRegex);
// --- INICIO DE LA CORRECCIÓN ---
// Si encontramos un nuevo enlace, actualizamos el contexto.
// Si NO encontramos un enlace, ya no hacemos nada, permitiendo que el contexto anterior persista.
if (match && match[1]) {
console.log("Noticia activa establecida:", match[1]);
setActiveArticleUrl(match[1]);
}
// HEMOS ELIMINADO EL BLOQUE "ELSE" QUE RESETEABA EL CONTEXTO.
// --- FIN DE LA CORRECCIÓN ---
break; break;
} }
// Acumulamos la respuesta cruda que viene del backend // ... (el resto del bucle while sigue exactamente igual)
accumulatedResponse += decoder.decode(value); const chunk = decoder.decode(value);
fullReply += chunk;
let cleanText = '';
try { try {
// Intentamos limpiar la respuesta acumulada const parsedArray = JSON.parse(fullReply.replace(/,$/, '') + ']');
// 1. Parseamos como si fuera un array JSON cleanText = Array.isArray(parsedArray) ? parsedArray.join('') : fullReply;
const parsedArray = JSON.parse(accumulatedResponse
// Añadimos un corchete de cierre por si el stream se corta a la mitad
.replace(/,$/, '') + ']');
// 2. Unimos los fragmentos del array en un solo texto
const cleanText = Array.isArray(parsedArray) ? parsedArray.join('') : accumulatedResponse;
// 3. Actualizamos el estado con el texto limpio
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
const updatedLastMessage = { ...lastMessage, text: cleanText };
return [...prev.slice(0, -1), updatedLastMessage];
});
} catch (e) { } catch (e) {
// Si hay un error de parseo (porque el JSON aún no está completo), cleanText = fullReply.replace(/^\["|"]$|","/g, '');
// mostramos el texto sin los caracteres iniciales/finales.
const partiallyCleanedText = accumulatedResponse
.replace(/^\[?"|"?,"?|"?\]$/g, '')
.replace(/","/g, '');
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
const updatedLastMessage = { ...lastMessage, text: partiallyCleanedText };
return [...prev.slice(0, -1), updatedLastMessage];
});
} }
setMessages(prev => {
const lastMessage = prev[prev.length - 1];
const updatedLastMessage = { ...lastMessage, text: cleanText };
return [...prev.slice(0, -1), updatedLastMessage];
});
} }
}; };
@@ -176,6 +186,11 @@ const Chatbot: React.FC = () => {
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{activeArticleUrl && (
<div className="context-indicator">
Hablando sobre: <span>Noticia actual</span>
</div>
)}
<form className="input-form" onSubmit={handleSendMessage}> <form className="input-form" onSubmit={handleSendMessage}>
<div className="input-container"> <div className="input-container">
<input <input