591 lines
29 KiB
C#
591 lines
29 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;
|
|
|
|
// 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 _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. " };
|
|
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.Url);
|
|
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.");
|
|
// 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(_knowledgeCacheKey, 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(_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
|
|
{
|
|
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(string url)
|
|
{
|
|
return await _cache.GetOrCreateAsync($"scrape_{url}", async entry =>
|
|
{
|
|
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url);
|
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
|
|
|
|
var web = new HtmlWeb();
|
|
var doc = await web.LoadFromWebAsync(url);
|
|
var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
|
|
|
|
if (mainContentNode == null) return string.Empty;
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
} |