2025-11-18 14:34:26 -03:00
// 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 ;
2025-11-21 13:05:40 -03:00
using System.Globalization ;
2025-11-25 11:46:52 -03:00
using ChatbotApi.Services ;
2025-11-18 14:34:26 -03:00
// Clases de Request/Response
2025-11-20 15:24:47 -03:00
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 ; }
}
2025-11-18 14:34:26 -03:00
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 ! ; }
2025-11-21 12:51:00 -03:00
public class NewsArticleLink
{
public required string Title { get ; set ; }
public required string Url { get ; set ; }
}
2025-11-21 12:10:45 -03:00
public enum IntentType { Article , KnowledgeBase , Homepage }
2025-11-18 14:34:26 -03:00
namespace ChatbotApi.Controllers
{
[ApiController]
[Route("api/[controller] ")]
public class ChatController : ControllerBase
{
private readonly string _apiUrl ;
private readonly IMemoryCache _cache ;
2025-11-20 10:52:46 -03:00
private readonly IServiceProvider _serviceProvider ;
2025-11-18 14:34:26 -03:00
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. " } ;
2025-11-20 15:24:47 -03:00
const int OutTokens = 8192 ;
2025-11-21 10:21:34 -03:00
2025-11-18 14:34:26 -03:00
public ChatController ( IConfiguration configuration , IMemoryCache memoryCache , IServiceProvider serviceProvider , ILogger < ChatController > logger )
{
_logger = logger ;
_cache = memoryCache ;
_serviceProvider = serviceProvider ;
2025-11-21 12:10:45 -03:00
var apiKey = configuration [ "Gemini:GeminiApiKey" ] ? ? throw new InvalidOperationException ( "La API Key de Gemini no está configurada en .env" ) ;
2025-11-18 14:34:26 -03:00
var baseUrl = configuration [ "Gemini:GeminiApiUrl" ] ;
_apiUrl = $"{baseUrl}{apiKey}" ;
}
2025-11-20 12:39:23 -03:00
2025-11-21 10:21:34 -03:00
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 ? ? "" ;
}
}
2025-11-21 12:10:45 -03:00
private async Task < IntentType > GetIntentAsync ( string userMessage , string? activeArticleContent , string? conversationSummary )
2025-11-20 10:52:46 -03:00
{
var promptBuilder = new StringBuilder ( ) ;
2025-11-21 12:10:45 -03:00
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." ) ;
2025-11-20 10:52:46 -03:00
2025-11-21 10:21:34 -03:00
if ( ! string . IsNullOrWhiteSpace ( conversationSummary ) )
{
promptBuilder . AppendLine ( "\n--- RESUMEN DE LA CONVERSACIÓN ACTUAL ---" ) ;
promptBuilder . AppendLine ( conversationSummary ) ;
}
2025-11-20 10:52:46 -03:00
if ( ! string . IsNullOrEmpty ( activeArticleContent ) )
{
2025-11-21 11:20:44 -03:00
promptBuilder . AppendLine ( "\n--- CONTEXTO DEL ARTÍCULO ACTUAL ---" ) ;
2025-11-20 10:52:46 -03:00
promptBuilder . AppendLine ( new string ( activeArticleContent . Take ( 500 ) . ToArray ( ) ) + "..." ) ;
}
promptBuilder . AppendLine ( "\n--- PREGUNTA DEL USUARIO ---" ) ;
promptBuilder . AppendLine ( userMessage ) ;
2025-11-21 12:10:45 -03:00
promptBuilder . AppendLine ( "\n--- CATEGORÍA SELECCIONADA ---" ) ;
2025-11-20 10:52:46 -03:00
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 ) ;
2025-11-21 12:10:45 -03:00
if ( ! response . IsSuccessStatusCode ) return IntentType . Homepage ;
2025-11-18 14:34:26 -03:00
2025-11-20 10:52:46 -03:00
var geminiResponse = await response . Content . ReadFromJsonAsync < GeminiResponse > ( ) ;
var responseText = geminiResponse ? . Candidates ? . FirstOrDefault ( ) ? . Content ? . Parts ? . FirstOrDefault ( ) ? . Text ? . Trim ( ) ? ? "" ;
2025-11-21 12:10:45 -03:00
_logger . LogInformation ( "Intención detectada: {Intent}" , responseText ) ;
2025-11-20 10:52:46 -03:00
2025-11-21 12:10:45 -03:00
if ( responseText . Contains ( "ARTICULO_ACTUAL" ) ) return IntentType . Article ;
if ( responseText . Contains ( "BASE_DE_CONOCIMIENTO" ) ) return IntentType . KnowledgeBase ;
return IntentType . Homepage ;
2025-11-20 10:52:46 -03:00
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Excepción en GetIntentAsync. Usando fallback a Homepage." ) ;
2025-11-21 12:10:45 -03:00
return IntentType . Homepage ;
2025-11-20 10:52:46 -03:00
}
}
2025-11-20 12:39:23 -03:00
2025-11-18 14:34:26 -03:00
[HttpPost("stream-message")]
[EnableRateLimiting("fixed")]
public async IAsyncEnumerable < string > StreamMessage (
2025-11-20 15:24:47 -03:00
[FromBody] ChatRequest request ,
[EnumeratorCancellation] CancellationToken cancellationToken )
2025-11-18 14:34:26 -03:00
{
if ( string . IsNullOrWhiteSpace ( request ? . Message ) )
{
yield return "Error: No he recibido ningún mensaje." ;
yield break ;
}
string userMessage = request . Message ;
2025-11-20 10:52:46 -03:00
string context = "" ;
string promptInstructions = "" ;
string? articleContext = null ;
string? errorMessage = null ;
2025-11-20 15:24:47 -03:00
IntentType intent = IntentType . Homepage ;
2025-11-20 12:39:23 -03:00
2025-11-18 14:34:26 -03:00
try
{
2025-11-20 10:52:46 -03:00
if ( ! string . IsNullOrEmpty ( request . ContextUrl ) )
{
articleContext = await GetArticleContentAsync ( request . ContextUrl ) ;
}
2025-11-21 10:21:34 -03:00
2025-11-21 12:10:45 -03:00
intent = await GetIntentAsync ( userMessage , articleContext , request . ConversationSummary ) ;
2025-11-20 10:52:46 -03:00
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 ;
2025-11-21 12:10:45 -03:00
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 )
2025-11-21 11:20:44 -03:00
{
2025-11-21 12:10:45 -03:00
contextBuilder . AppendLine ( $"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}" ) ;
2025-11-21 11:20:44 -03:00
}
2025-11-21 12:10:45 -03:00
var fuentesExternas = await GetFuentesDeContextoAsync ( ) ;
foreach ( var fuente in fuentesExternas )
2025-11-21 11:20:44 -03:00
{
2025-11-21 12:10:45 -03:00
contextBuilder . AppendLine ( $"\n--- Información de la página '{fuente.Nombre}' ---" ) ;
2025-11-25 11:46:52 -03:00
string scrapedContent = await ScrapeUrlContentAsync ( fuente ) ;
2025-11-21 12:10:45 -03:00
contextBuilder . AppendLine ( scrapedContent ) ;
2025-11-21 11:20:44 -03:00
}
2025-11-21 12:10:45 -03:00
context = contextBuilder . ToString ( ) ;
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en la 'BASE DE CONOCIMIENTO' proporcionada." ;
2025-11-20 10:52:46 -03:00
break ;
case IntentType . Homepage :
default :
_logger . LogInformation ( "Ejecutando intención: Noticias de Portada." ) ;
2025-11-21 12:51:00 -03:00
// 1. Obtenemos la lista de artículos de la portada.
2025-11-21 13:05:40 -03:00
var articles = await GetWebsiteNewsAsync ( _siteUrl , 50 ) ;
2025-11-21 12:51:00 -03:00
// 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}" ) ;
}
2025-11-21 14:19:04 -03:00
context = homepageContextBuilder . ToString ( ) ;
2025-11-21 12:51:00 -03:00
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." ;
}
2025-11-21 11:20:44 -03:00
break ;
2025-11-20 10:52:46 -03:00
}
2025-11-18 14:34:26 -03:00
}
catch ( Exception ex )
{
2025-11-20 10:52:46 -03:00
_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." ;
2025-11-18 14:34:26 -03:00
}
2025-11-20 12:39:23 -03:00
yield return $"INTENT::{intent}" ;
2025-11-18 14:34:26 -03:00
if ( ! string . IsNullOrEmpty ( errorMessage ) )
{
yield return errorMessage ;
yield break ;
}
Stream ? responseStream = null ;
2025-11-21 10:21:34 -03:00
var fullBotReply = new StringBuilder ( ) ;
2025-11-18 14:34:26 -03:00
try
{
var promptBuilder = new StringBuilder ( ) ;
promptBuilder . AppendLine ( "INSTRUCCIONES:" ) ;
2025-11-25 11:46:52 -03:00
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" ) ;
2025-11-21 13:05:40 -03:00
// 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 ( "--------------------------------------------------" ) ;
}
2025-11-25 11:46:52 -03:00
catch ( Exception ex )
2025-11-21 13:05:40 -03:00
{
_logger . LogWarning ( ex , "No se pudo determinar la zona horaria de Argentina. El contexto de tiempo será omitido." ) ;
}
2025-11-20 10:52:46 -03:00
promptBuilder . AppendLine ( promptInstructions ) ;
2025-11-18 14:34:26 -03:00
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 ;
2025-11-20 15:24:47 -03:00
var requestData = new GeminiRequest
{
Contents = new [ ] { new Content { Parts = new [ ] { new Part { Text = finalPrompt } } } } ,
GenerationConfig = new GenerationConfig { MaxOutputTokens = OutTokens }
} ;
2025-11-18 14:34:26 -03:00
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 ( ) ;
2025-11-20 15:24:47 -03:00
_logger . LogWarning ( "La API (Streaming) devolvió un error. Status: {StatusCode}, Content: {ErrorContent}" , response . StatusCode , errorContent ) ;
throw new HttpRequestException ( "La API devolvió un error." ) ;
2025-11-18 14:34:26 -03:00
}
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 ) ;
2025-11-20 10:52:46 -03:00
string? chunk = null ;
2025-11-18 14:34:26 -03:00
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 )
{
2025-11-21 10:21:34 -03:00
// Guardamos el log de la conversación como antes
2025-11-18 14:34:26 -03:00
await SaveConversationLogAsync ( userMessage , fullBotReply . ToString ( ) ) ;
2025-11-21 10:21:34 -03:00
// 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}" ;
2025-11-18 14:34:26 -03:00
}
}
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." ) ;
}
}
2025-11-20 12:39:23 -03:00
2025-11-21 12:51:00 -03:00
private async Task < List < NewsArticleLink > > GetWebsiteNewsAsync ( string url , int cantidad )
2025-11-18 14:34:26 -03:00
{
2025-11-21 12:51:00 -03:00
var newsList = new List < NewsArticleLink > ( ) ;
2025-11-18 14:34:26 -03:00
try
{
var web = new HtmlWeb ( ) ;
var doc = await web . LoadFromWebAsync ( url ) ;
2025-11-20 10:52:46 -03:00
var articleNodes = doc . DocumentNode . SelectNodes ( "//article[contains(@class, 'item')]" ) ;
2025-11-21 12:51:00 -03:00
if ( articleNodes = = null ) return newsList ;
2025-11-18 14:34:26 -03:00
var urlsProcesadas = new HashSet < string > ( ) ;
2025-11-20 10:52:46 -03:00
foreach ( var articleNode in articleNodes )
2025-11-18 14:34:26 -03:00
{
2025-11-21 12:51:00 -03:00
if ( newsList . Count > = cantidad ) break ;
2025-11-20 10:52:46 -03:00
var linkNode = articleNode . SelectSingleNode ( ".//a[@href]" ) ;
var titleNode = articleNode . SelectSingleNode ( ".//h2" ) ;
2025-11-18 14:34:26 -03:00
2025-11-20 10:52:46 -03:00
if ( linkNode ! = null & & titleNode ! = null )
2025-11-18 14:34:26 -03:00
{
2025-11-20 10:52:46 -03:00
var relativeUrl = linkNode . GetAttributeValue ( "href" , string . Empty ) ;
2025-11-21 12:51:00 -03:00
if ( ! string . IsNullOrEmpty ( relativeUrl ) & & relativeUrl ! = "#" & & ! urlsProcesadas . Contains ( relativeUrl ) )
2025-11-20 10:52:46 -03:00
{
2025-11-21 12:51:00 -03:00
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 ) ;
2025-11-20 10:52:46 -03:00
}
2025-11-21 12:51:00 -03:00
}
}
}
catch ( Exception ex )
{
_logger . LogError ( ex , "No se pudo descargar o procesar la URL {Url}" , url ) ;
}
return newsList ;
}
2025-11-20 10:52:46 -03:00
2025-11-21 12:51:00 -03:00
private async Task < NewsArticleLink ? > FindBestMatchingArticleAsync ( string userMessage , List < NewsArticleLink > articles )
{
if ( ! articles . Any ( ) ) return null ;
2025-11-20 10:52:46 -03:00
2025-11-21 12:51:00 -03:00
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 ---" ) ;
2025-11-20 10:52:46 -03:00
2025-11-21 12:51:00 -03:00
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?" ) ;
2025-11-20 10:52:46 -03:00
2025-11-21 12:51:00 -03:00
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 ) ;
2025-11-18 14:34:26 -03:00
}
catch ( Exception ex )
{
2025-11-21 12:51:00 -03:00
_logger . LogError ( ex , "Excepción en FindBestMatchingArticleAsync." ) ;
return null ;
2025-11-18 14:34:26 -03:00
}
}
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 ;
}
2025-11-20 10:52:46 -03:00
2025-11-21 12:10:45 -03:00
private async Task < Dictionary < string , ContextoItem > > GetKnowledgeItemsAsync ( )
2025-11-18 14:34:26 -03:00
{
2025-11-25 11:46:52 -03:00
return await _cache . GetOrCreateAsync ( CacheKeys . KnowledgeItems , async entry = >
2025-11-18 14:34:26 -03:00
{
2025-11-21 12:10:45 -03:00
_logger . LogInformation ( "Cargando ContextoItems desde la base de datos a la caché..." ) ;
2025-11-18 14:34:26 -03:00
entry . AbsoluteExpirationRelativeToNow = TimeSpan . FromMinutes ( 5 ) ;
using ( var scope = _serviceProvider . CreateScope ( ) )
{
var dbContext = scope . ServiceProvider . GetRequiredService < AppContexto > ( ) ;
2025-11-21 12:10:45 -03:00
return await dbContext . ContextoItems . AsNoTracking ( ) . ToDictionaryAsync ( item = > item . Clave , item = > item ) ;
2025-11-18 14:34:26 -03:00
}
2025-11-21 11:20:44 -03:00
} ) ? ? new Dictionary < string , ContextoItem > ( ) ;
2025-11-20 10:52:46 -03:00
}
2025-11-21 12:10:45 -03:00
private async Task < List < FuenteContexto > > GetFuentesDeContextoAsync ( )
{
2025-11-25 11:46:52 -03:00
return await _cache . GetOrCreateAsync ( CacheKeys . FuentesDeContexto , async entry = >
2025-11-21 12:10:45 -03:00
{
_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 > ( ) ;
}
2025-11-20 10:52:46 -03:00
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 ;
}
2025-11-18 14:34:26 -03:00
}
2025-11-21 11:20:44 -03:00
2025-11-25 11:46:52 -03:00
private async Task < string > ScrapeUrlContentAsync ( FuenteContexto fuente )
2025-11-21 11:20:44 -03:00
{
2025-11-25 11:46:52 -03:00
// La clave de caché sigue siendo la misma.
var result = await _cache . GetOrCreateAsync ( $"scrape_{fuente.Url}_{fuente.SelectorContenido}" , async entry = >
2025-11-21 11:20:44 -03:00
{
2025-11-25 11:46:52 -03:00
_logger . LogInformation ( "Contenido de {Url} no encontrado en caché. Scrapeando..." , fuente . Url ) ;
2025-11-21 12:10:45 -03:00
entry . AbsoluteExpirationRelativeToNow = TimeSpan . FromMinutes ( 30 ) ;
2025-11-21 11:20:44 -03:00
var web = new HtmlWeb ( ) ;
2025-11-25 11:46:52 -03:00
var doc = await web . LoadFromWebAsync ( fuente . Url ) ;
2025-11-21 11:20:44 -03:00
2025-11-25 11:46:52 -03:00
HtmlNode ? contentNode ;
string selectorUsado ;
2025-11-21 11:20:44 -03:00
2025-11-25 11:46:52 -03:00
// 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" ) ;
}
2025-11-21 12:10:45 -03:00
2025-11-25 11:46:52 -03:00
if ( contentNode = = null )
2025-11-21 12:10:45 -03:00
{
2025-11-25 11:46:52 -03:00
_logger . LogWarning ( "No se encontró contenido en {Url} con el selector '{Selector}'" , fuente . Url , selectorUsado ) ;
return string . Empty ;
2025-11-21 12:10:45 -03:00
}
2025-11-25 11:46:52 -03:00
_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 ;
2025-11-21 11:20:44 -03:00
}
2025-11-18 14:34:26 -03:00
}
}