2025-11-18 14:34:26 -03:00
// ChatbotApi/Controllers/ChatController.cs
using Microsoft.AspNetCore.Mvc ;
using ChatbotApi.Models ;
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 ;
// 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-20 10:52:46 -03:00
public enum IntentType { Article , Database , 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 ( ) ;
2025-11-20 10:52:46 -03:00
private static readonly string _knowledgeCacheKey = "KnowledgeBase" ;
2025-11-18 14:34:26 -03:00
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-18 14:34:26 -03:00
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}" ;
}
2025-11-20 12:39:23 -03:00
2025-11-20 10:52:46 -03:00
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 ;
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 ( ) ? ? "" ;
_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 ;
}
}
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-20 15:24:47 -03:00
2025-11-20 12:39:23 -03:00
intent = await GetIntentAsync ( userMessage , articleContext ) ;
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 ;
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." ) ;
2025-11-20 12:39:23 -03:00
context = await GetWebsiteNewsAsync ( _siteUrl , 25 ) ;
2025-11-20 10:52:46 -03:00
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 ;
}
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 ;
try
{
var promptBuilder = new StringBuilder ( ) ;
promptBuilder . AppendLine ( "INSTRUCCIONES:" ) ;
2025-11-20 15:24:47 -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." ) ;
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 ;
}
var fullBotReply = new StringBuilder ( ) ;
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 )
{
await SaveConversationLogAsync ( userMessage , fullBotReply . ToString ( ) ) ;
}
}
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-20 10:52:46 -03:00
private async Task < string? > FindBestDbItemAsync ( string userMessage , Dictionary < string , string > knowledgeBase )
2025-11-18 14:34:26 -03:00
{
2025-11-20 10:52:46 -03:00
if ( knowledgeBase = = null | | ! knowledgeBase . Any ( ) ) return null ;
2025-11-18 14:34:26 -03:00
2025-11-20 10:52:46 -03:00
var availableKeys = string . Join ( ", " , knowledgeBase . Keys ) ;
2025-11-18 14:34:26 -03:00
2025-11-20 10:52:46 -03:00
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 ---" ) ;
2025-11-18 14:34:26 -03:00
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?" ) ;
2025-11-18 14:34:26 -03:00
2025-11-20 10:52:46 -03:00
try
{
var response = await _httpClient . PostAsJsonAsync ( nonStreamingApiUrl , requestData ) ;
if ( ! response . IsSuccessStatusCode ) return null ;
2025-11-18 14:34:26 -03:00
var geminiResponse = await response . Content . ReadFromJsonAsync < GeminiResponse > ( ) ;
2025-11-20 10:52:46 -03:00
var bestKey = geminiResponse ? . Candidates ? . FirstOrDefault ( ) ? . Content ? . Parts ? . FirstOrDefault ( ) ? . Text ? . Trim ( ) ;
2025-11-18 14:34:26 -03:00
2025-11-20 10:52:46 -03:00
if ( bestKey ! = null & & knowledgeBase . TryGetValue ( bestKey , out var contextValue ) )
2025-11-18 14:34:26 -03:00
{
2025-11-20 10:52:46 -03:00
_logger . LogInformation ( "Búsqueda en DB por IA: La pregunta '{userMessage}' se asoció con la clave '{bestKey}'." , userMessage , bestKey ) ;
return contextValue ;
2025-11-18 14:34:26 -03:00
}
2025-11-20 10:52:46 -03:00
_logger . LogWarning ( "Búsqueda en DB por IA: La clave devuelta '{bestKey}' no es válida." , bestKey ) ;
return null ;
2025-11-18 14:34:26 -03:00
}
2025-11-20 10:52:46 -03:00
catch ( Exception ex )
2025-11-18 14:34:26 -03:00
{
2025-11-20 10:52:46 -03:00
_logger . LogError ( ex , "Excepción en FindBestDbItemAsync." ) ;
return null ;
2025-11-18 14:34:26 -03:00
}
}
private async Task < string > GetWebsiteNewsAsync ( string url , int cantidad )
{
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')]" ) ;
if ( articleNodes = = null | | ! articleNodes . Any ( ) )
{
_logger . LogWarning ( "No se encontraron nodos de <article> en la URL {Url}" , url ) ;
return string . Empty ;
}
2025-11-18 14:34:26 -03:00
var contextBuilder = new StringBuilder ( ) ;
contextBuilder . AppendLine ( "Lista de noticias principales extraídas de la página:" ) ;
var urlsProcesadas = new HashSet < string > ( ) ;
int count = 0 ;
2025-11-20 10:52:46 -03:00
foreach ( var articleNode in articleNodes )
2025-11-18 14:34:26 -03:00
{
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 ) ;
if ( string . IsNullOrEmpty ( relativeUrl ) | | relativeUrl = = "#" | | urlsProcesadas . Contains ( relativeUrl ) )
{
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 ) ;
2025-11-18 14:34:26 -03:00
count + + ;
}
2025-11-20 10:52:46 -03:00
if ( count > = cantidad )
{
break ;
}
2025-11-18 14:34:26 -03:00
}
2025-11-20 10:52:46 -03:00
var result = contextBuilder . ToString ( ) ;
_logger . LogInformation ( "Scraping de la portada exitoso. Se encontraron {Count} noticias." , count ) ;
return result ;
2025-11-18 14:34:26 -03:00
}
catch ( Exception ex )
{
_logger . LogError ( ex , "No se pudo descargar o procesar la URL {Url}" , url ) ;
return string . Empty ;
}
}
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-18 14:34:26 -03:00
private async Task < Dictionary < string , string > > GetKnowledgeAsync ( )
{
return await _cache . GetOrCreateAsync ( _knowledgeCacheKey , async entry = >
{
_logger . LogInformation ( "La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos..." ) ;
entry . AbsoluteExpirationRelativeToNow = TimeSpan . FromMinutes ( 5 ) ;
using ( var scope = _serviceProvider . CreateScope ( ) )
{
var dbContext = scope . ServiceProvider . GetRequiredService < AppContexto > ( ) ;
var knowledge = await dbContext . ContextoItems
2025-11-20 10:52:46 -03:00
. AsNoTracking ( )
2025-11-18 14:34:26 -03:00
. ToDictionaryAsync ( item = > item . Clave , item = > item . Valor ) ;
_logger . LogInformation ( $"Caché actualizada con {knowledge.Count} items." ) ;
return knowledge ;
}
2025-11-20 10:52:46 -03:00
} ) ? ? 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 ;
}
2025-11-18 14:34:26 -03:00
}
}
}