Se soluciona un bug donde el chatbot no reconocía las nuevas fuentes de conocimiento o los items de contexto añadidos desde el panel de administración hasta que la API se reiniciaba. Ahora, el AdminController borra la caché correspondiente después de cada operación de Crear, Actualizar o Eliminar. Esto fuerza al chatbot a recargar la información desde la base de datos en la siguiente petición, haciendo que los cambios se reflejen de forma inmediata. Se añade un nuevo campo para determinar el selector de contenido dentro de la web al momento de realizar el scrap.
608 lines
30 KiB
C#
608 lines
30 KiB
C#
// ChatbotApi/Controllers/ChatController.cs
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using ChatbotApi.Data.Models;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using HtmlAgilityPack;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using System.Globalization;
|
|
using ChatbotApi.Services;
|
|
|
|
// Clases de Request/Response
|
|
public class GenerationConfig
|
|
{
|
|
[JsonPropertyName("maxOutputTokens")]
|
|
public int MaxOutputTokens { get; set; }
|
|
}
|
|
|
|
public class GeminiRequest
|
|
{
|
|
[JsonPropertyName("contents")]
|
|
public Content[] Contents { get; set; } = default!;
|
|
|
|
[JsonPropertyName("generationConfig")]
|
|
public GenerationConfig? GenerationConfig { get; set; }
|
|
}
|
|
|
|
public class Content { [JsonPropertyName("parts")] public Part[] Parts { get; set; } = default!; }
|
|
public class Part { [JsonPropertyName("text")] public string Text { get; set; } = default!; }
|
|
public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[] Candidates { 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 StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
|
|
public class NewsArticleLink
|
|
{
|
|
public required string Title { get; set; }
|
|
public required string Url { get; set; }
|
|
}
|
|
public enum IntentType { Article, KnowledgeBase, Homepage }
|
|
|
|
namespace ChatbotApi.Controllers
|
|
{
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class ChatController : ControllerBase
|
|
{
|
|
private readonly string _apiUrl;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly ILogger<ChatController> _logger;
|
|
private static readonly HttpClient _httpClient = new HttpClient();
|
|
private static readonly string _siteUrl = "https://www.eldia.com/";
|
|
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
|
|
const int OutTokens = 8192;
|
|
|
|
public ChatController(IConfiguration configuration, IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger<ChatController> logger)
|
|
{
|
|
_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 baseUrl = configuration["Gemini:GeminiApiUrl"];
|
|
_apiUrl = $"{baseUrl}{apiKey}";
|
|
}
|
|
|
|
private async Task<string> UpdateConversationSummaryAsync(string? oldSummary, string userMessage, string botResponse)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(oldSummary))
|
|
{
|
|
oldSummary = "Esta es una nueva conversación.";
|
|
}
|
|
|
|
var promptBuilder = new StringBuilder();
|
|
promptBuilder.AppendLine("Tu tarea es actualizar un resumen de conversación. Basado en el RESUMEN ANTERIOR y el ÚLTIMO INTERCAMBIO, crea un nuevo resumen conciso. Mantén solo los puntos clave y el tema principal de la conversación.");
|
|
promptBuilder.AppendLine("\n--- RESUMEN ANTERIOR ---");
|
|
promptBuilder.AppendLine(oldSummary);
|
|
promptBuilder.AppendLine("\n--- ÚLTIMO INTERCAMBIO ---");
|
|
promptBuilder.AppendLine($"Usuario: \"{userMessage}\"");
|
|
promptBuilder.AppendLine($"Bot: \"{new string(botResponse.Take(300).ToArray())}...\"");
|
|
promptBuilder.AppendLine("\n--- NUEVO RESUMEN CONCISO ---");
|
|
|
|
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 oldSummary ?? "";
|
|
|
|
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
|
var newSummary = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
|
|
|
|
_logger.LogInformation("Resumen de conversación actualizado: '{NewSummary}'", newSummary);
|
|
return newSummary ?? oldSummary ?? "";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Excepción en UpdateConversationSummaryAsync. Se mantendrá el resumen anterior.");
|
|
return oldSummary ?? "";
|
|
}
|
|
}
|
|
|
|
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. 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))
|
|
{
|
|
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ACTUAL ---");
|
|
promptBuilder.AppendLine(conversationSummary);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(activeArticleContent))
|
|
{
|
|
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--- CATEGORÍA 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_CONOCIMIENTO")) return IntentType.KnowledgeBase;
|
|
return IntentType.Homepage;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
|
|
return IntentType.Homepage;
|
|
}
|
|
}
|
|
|
|
[HttpPost("stream-message")]
|
|
[EnableRateLimiting("fixed")]
|
|
public async IAsyncEnumerable<string> StreamMessage(
|
|
[FromBody] ChatRequest request,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request?.Message))
|
|
{
|
|
yield return "Error: No he recibido ningún mensaje.";
|
|
yield break;
|
|
}
|
|
|
|
string userMessage = request.Message;
|
|
string context = "";
|
|
string promptInstructions = "";
|
|
string? articleContext = null;
|
|
string? errorMessage = null;
|
|
IntentType intent = IntentType.Homepage;
|
|
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(request.ContextUrl))
|
|
{
|
|
articleContext = await GetArticleContentAsync(request.ContextUrl);
|
|
}
|
|
|
|
intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary);
|
|
|
|
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.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)
|
|
{
|
|
contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}");
|
|
}
|
|
|
|
var fuentesExternas = await GetFuentesDeContextoAsync();
|
|
foreach (var fuente in fuentesExternas)
|
|
{
|
|
contextBuilder.AppendLine($"\n--- Información de la página '{fuente.Nombre}' ---");
|
|
|
|
string scrapedContent = await ScrapeUrlContentAsync(fuente);
|
|
|
|
contextBuilder.AppendLine(scrapedContent);
|
|
}
|
|
|
|
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:
|
|
default:
|
|
_logger.LogInformation("Ejecutando intención: Noticias de Portada.");
|
|
|
|
// 1. Obtenemos la lista de artículos de la portada.
|
|
var articles = await GetWebsiteNewsAsync(_siteUrl, 50);
|
|
|
|
// 2. Usamos la IA para encontrar el mejor artículo.
|
|
var bestMatch = await FindBestMatchingArticleAsync(userMessage, articles);
|
|
|
|
if (bestMatch != null)
|
|
{
|
|
// 3. SI ENCONTRAMOS UN ARTÍCULO: Scrapeamos su contenido y preparamos el prompt de síntesis.
|
|
_logger.LogInformation("Artículo relevante encontrado: {Title}", bestMatch.Title);
|
|
string articleContent = await GetArticleContentAsync(bestMatch.Url) ?? "No se pudo leer el contenido del artículo.";
|
|
context = articleContent;
|
|
promptInstructions = $"La pregunta del usuario es '{userMessage}'. Basado en el CONTEXTO (el contenido de un artículo), tu tarea es:\n1. Escribir un resumen muy conciso (una o dos frases) que responda directamente a la pregunta del usuario.\n2. Incluir el título completo del artículo y su enlace en formato Markdown: '[{bestMatch.Title}]({bestMatch.Url})'.\n3. Invitar amablemente al usuario a preguntar más sobre este tema.";
|
|
}
|
|
else
|
|
{
|
|
// 4. SI NO ENCONTRAMOS NADA: Fallback al comportamiento antiguo de mostrar la lista.
|
|
_logger.LogInformation("No se encontró un artículo específico. Mostrando un resumen general de la portada.");
|
|
var homepageContextBuilder = new StringBuilder();
|
|
homepageContextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
|
|
foreach (var article in articles)
|
|
{
|
|
homepageContextBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}");
|
|
}
|
|
|
|
context = homepageContextBuilder.ToString();
|
|
|
|
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote en la siguiente lista de noticias de portada. Si no encuentras una respuesta directa, informa al usuario sobre los temas principales disponibles.";
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error al procesar la intención y el contexto.");
|
|
errorMessage = "Error: Lo siento, estoy teniendo un problema técnico al procesar tu pregunta.";
|
|
}
|
|
|
|
yield return $"INTENT::{intent}";
|
|
|
|
if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
yield return errorMessage;
|
|
yield break;
|
|
}
|
|
|
|
Stream? responseStream = null;
|
|
var fullBotReply = new StringBuilder();
|
|
|
|
try
|
|
{
|
|
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. Responde siempre en español Rioplatense. El usuario se encuentra navegando en la web de eldia.com");
|
|
// CONTEXTO FIJO
|
|
try
|
|
{
|
|
// Forzamos la zona horaria de Argentina para ser independientes de la configuración del servidor.
|
|
var argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
|
|
var localTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone);
|
|
var formattedTime = localTime.ToString("dddd, dd/MM/yyyy HH:mm 'Hs.'", new CultureInfo("es-AR"));
|
|
|
|
promptBuilder.AppendLine("\n--- CONTEXTO FIJO ESPACIO-TEMPORAL (Tu Identidad) ---");
|
|
promptBuilder.AppendLine($"Tu base de operaciones y el foco principal de tus noticias es La Plata, Provincia de Buenos Aires, Argentina.");
|
|
promptBuilder.AppendLine($"La fecha y hora actual en La Plata es: {formattedTime}.");
|
|
promptBuilder.AppendLine("Usa esta información para dar contexto a las noticias y responder preguntas sobre el día o la ubicación.");
|
|
promptBuilder.AppendLine("--------------------------------------------------");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "No se pudo determinar la zona horaria de Argentina. El contexto de tiempo será omitido.");
|
|
}
|
|
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("\nCONTEXTO:\n---");
|
|
promptBuilder.AppendLine(context);
|
|
promptBuilder.AppendLine("---\n\nPREGUNTA DEL USUARIO:\n---");
|
|
promptBuilder.AppendLine(userMessage);
|
|
promptBuilder.AppendLine("---\n\nRESPUESTA:");
|
|
string finalPrompt = promptBuilder.ToString();
|
|
|
|
var streamingApiUrl = _apiUrl;
|
|
|
|
var requestData = new GeminiRequest
|
|
{
|
|
Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } },
|
|
GenerationConfig = new GenerationConfig { MaxOutputTokens = OutTokens }
|
|
};
|
|
|
|
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, streamingApiUrl);
|
|
httpRequestMessage.Content = JsonContent.Create(requestData);
|
|
|
|
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("La API (Streaming) devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent);
|
|
throw new HttpRequestException("La API devolvió un error.");
|
|
}
|
|
|
|
responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error inesperado durante la configuración del stream.");
|
|
errorMessage = "Error: Lo siento, estoy teniendo un problema técnico.";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
yield return errorMessage;
|
|
yield break;
|
|
}
|
|
|
|
if (responseStream != null)
|
|
{
|
|
await using (responseStream)
|
|
using (var reader = new StreamReader(responseStream))
|
|
{
|
|
string? line;
|
|
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue;
|
|
|
|
var jsonString = line.Substring(6);
|
|
string? chunk = null;
|
|
|
|
try
|
|
{
|
|
var geminiResponse = JsonSerializer.Deserialize<GeminiStreamingResponse>(jsonString);
|
|
chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString);
|
|
continue;
|
|
}
|
|
|
|
if (chunk != null)
|
|
{
|
|
fullBotReply.Append(chunk);
|
|
yield return chunk;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fullBotReply.Length > 0)
|
|
{
|
|
// Guardamos el log de la conversación como antes
|
|
await SaveConversationLogAsync(userMessage, fullBotReply.ToString());
|
|
|
|
// Creamos el nuevo resumen
|
|
var newSummary = await UpdateConversationSummaryAsync(request.ConversationSummary, userMessage, fullBotReply.ToString());
|
|
|
|
// Enviamos el nuevo resumen al frontend como el último mensaje del stream
|
|
yield return $"SUMMARY::{newSummary}";
|
|
}
|
|
}
|
|
|
|
private async Task SaveConversationLogAsync(string userMessage, string botReply)
|
|
{
|
|
try
|
|
{
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
|
var logEntry = new ConversacionLog
|
|
{
|
|
UsuarioMensaje = userMessage,
|
|
BotRespuesta = botReply,
|
|
Fecha = DateTime.UtcNow
|
|
};
|
|
dbContext.ConversacionLogs.Add(logEntry);
|
|
await dbContext.SaveChangesAsync();
|
|
}
|
|
}
|
|
catch (Exception logEx)
|
|
{
|
|
_logger.LogError(logEx, "Error al guardar el log de la conversación después del streaming.");
|
|
}
|
|
}
|
|
|
|
private async Task<List<NewsArticleLink>> GetWebsiteNewsAsync(string url, int cantidad)
|
|
{
|
|
var newsList = new List<NewsArticleLink>();
|
|
try
|
|
{
|
|
var web = new HtmlWeb();
|
|
var doc = await web.LoadFromWebAsync(url);
|
|
var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
|
|
|
|
if (articleNodes == null) return newsList;
|
|
|
|
var urlsProcesadas = new HashSet<string>();
|
|
foreach (var articleNode in articleNodes)
|
|
{
|
|
if (newsList.Count >= cantidad) break;
|
|
|
|
var linkNode = articleNode.SelectSingleNode(".//a[@href]");
|
|
var titleNode = articleNode.SelectSingleNode(".//h2");
|
|
|
|
if (linkNode != null && titleNode != null)
|
|
{
|
|
var relativeUrl = linkNode.GetAttributeValue("href", string.Empty);
|
|
if (!string.IsNullOrEmpty(relativeUrl) && relativeUrl != "#" && !urlsProcesadas.Contains(relativeUrl))
|
|
{
|
|
var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
|
|
newsList.Add(new NewsArticleLink
|
|
{
|
|
Title = CleanTitleText(titleNode.InnerText),
|
|
Url = fullUrl
|
|
});
|
|
urlsProcesadas.Add(relativeUrl);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url);
|
|
}
|
|
return newsList;
|
|
}
|
|
|
|
private async Task<NewsArticleLink?> FindBestMatchingArticleAsync(string userMessage, List<NewsArticleLink> articles)
|
|
{
|
|
if (!articles.Any()) return null;
|
|
|
|
var promptBuilder = new StringBuilder();
|
|
promptBuilder.AppendLine("Tu tarea es actuar como un motor de búsqueda. Dada una PREGUNTA DE USUARIO y una LISTA DE ARTÍCULOS, debes encontrar el artículo más relevante. Responde única y exclusivamente con la URL completa del artículo elegido. Si ningún artículo es relevante, responde con 'N/A'.");
|
|
promptBuilder.AppendLine("\n--- LISTA DE ARTÍCULOS ---");
|
|
foreach (var article in articles)
|
|
{
|
|
promptBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}");
|
|
}
|
|
promptBuilder.AppendLine("\n--- PREGUNTA DE USUARIO ---");
|
|
promptBuilder.AppendLine(userMessage);
|
|
promptBuilder.AppendLine("\n--- URL 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 responseUrl = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
|
|
|
|
if (string.IsNullOrEmpty(responseUrl) || responseUrl == "N/A") return null;
|
|
|
|
// Buscamos el artículo completo en nuestra lista original usando la URL que nos dio la IA
|
|
return articles.FirstOrDefault(a => a.Url == responseUrl);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Excepción en FindBestMatchingArticleAsync.");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private string CleanTitleText(string texto)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(texto)) return string.Empty;
|
|
var textoDecodificado = WebUtility.HtmlDecode(texto).Trim();
|
|
foreach (var prefijo in PrefijosAQuitar)
|
|
{
|
|
if (textoDecodificado.StartsWith(prefijo, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
textoDecodificado = textoDecodificado.Substring(prefijo.Length).Trim();
|
|
break;
|
|
}
|
|
}
|
|
return textoDecodificado;
|
|
}
|
|
|
|
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeItemsAsync()
|
|
{
|
|
return await _cache.GetOrCreateAsync(CacheKeys.KnowledgeItems, async entry =>
|
|
{
|
|
_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>();
|
|
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(CacheKeys.FuentesDeContexto, 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
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
private async Task<string> ScrapeUrlContentAsync(FuenteContexto fuente)
|
|
{
|
|
// La clave de caché sigue siendo la misma.
|
|
var result = await _cache.GetOrCreateAsync($"scrape_{fuente.Url}_{fuente.SelectorContenido}", async entry =>
|
|
{
|
|
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", fuente.Url);
|
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
|
|
|
|
var web = new HtmlWeb();
|
|
var doc = await web.LoadFromWebAsync(fuente.Url);
|
|
|
|
HtmlNode? contentNode;
|
|
string selectorUsado;
|
|
|
|
// Si se especificó un selector en la base de datos, lo usamos.
|
|
if (!string.IsNullOrWhiteSpace(fuente.SelectorContenido))
|
|
{
|
|
selectorUsado = fuente.SelectorContenido;
|
|
contentNode = doc.DocumentNode.SelectSingleNode(selectorUsado);
|
|
}
|
|
else
|
|
{
|
|
// Si no, usamos nuestro fallback genérico a <main> o <body>.
|
|
selectorUsado = "//main | //body";
|
|
contentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
|
|
}
|
|
|
|
if (contentNode == null)
|
|
{
|
|
_logger.LogWarning("No se encontró contenido en {Url} con el selector '{Selector}'", fuente.Url, selectorUsado);
|
|
return string.Empty;
|
|
}
|
|
|
|
_logger.LogInformation("Extrayendo texto de {Url} usando el selector '{Selector}'", fuente.Url, selectorUsado);
|
|
|
|
// --- LA LÓGICA CLAVE Y SIMPLIFICADA ---
|
|
// Extraemos TODO el texto visible dentro del nodo seleccionado, sin importar las etiquetas.
|
|
// InnerText es recursivo y obtiene el texto de todos los nodos hijos.
|
|
return WebUtility.HtmlDecode(contentNode.InnerText) ?? string.Empty;
|
|
});
|
|
return result ?? string.Empty;
|
|
}
|
|
}
|
|
} |