Files
Chatbot-ElDia/ChatbotApi/Constrollers/ChatController.cs

518 lines
24 KiB
C#
Raw Normal View History

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!; }
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;
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 _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
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 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.IsNullOrWhiteSpace(conversationSummary))
{
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ACTUAL ---");
promptBuilder.AppendLine(conversationSummary);
}
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
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;
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
{
if (!string.IsNullOrEmpty(request.ContextUrl))
{
articleContext = await GetArticleContentAsync(request.ContextUrl);
}
// Le pasamos el resumen al router de intenciones
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.Database:
_logger.LogInformation("Ejecutando intención: Base de Datos.");
var knowledgeBase = await GetKnowledgeAsync();
context = await FindBestDbItemAsync(userMessage, request.ConversationSummary, 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);
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)
{
_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;
var fullBotReply = new StringBuilder();
2025-11-18 14:34:26 -03:00
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.");
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);
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)
{
// Guardamos el log de la conversación como antes
2025-11-18 14:34:26 -03:00
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}";
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
private async Task<string?> FindBestDbItemAsync(string userMessage, string? conversationSummary, Dictionary<string, string> knowledgeBase)
2025-11-18 14:34:26 -03:00
{
if (knowledgeBase == null || !knowledgeBase.Any()) return null;
2025-11-18 14:34:26 -03:00
var availableKeys = string.Join(", ", knowledgeBase.Keys);
2025-11-18 14:34:26 -03:00
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un buscador semántico. Usa el RESUMEN para entender el contexto de la conversación. Basado en la PREGUNTA DEL USUARIO, elige la CLAVE más relevante de la lista. Responde única y exclusivamente con la clave que elijas.");
// Añadimos el resumen al prompt del buscador
if (!string.IsNullOrWhiteSpace(conversationSummary))
{
promptBuilder.AppendLine("\n--- RESUMEN DE LA CONVERSACIÓN ---");
promptBuilder.AppendLine(conversationSummary);
}
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
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
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>();
var bestKey = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
2025-11-18 14:34:26 -03:00
if (bestKey != null && knowledgeBase.TryGetValue(bestKey, out var contextValue))
2025-11-18 14:34:26 -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
}
_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
}
catch (Exception ex)
2025-11-18 14:34:26 -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);
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;
foreach (var articleNode in articleNodes)
2025-11-18 14:34:26 -03:00
{
var linkNode = articleNode.SelectSingleNode(".//a[@href]");
var titleNode = articleNode.SelectSingleNode(".//h2");
2025-11-18 14:34:26 -03:00
if (linkNode != null && titleNode != null)
2025-11-18 14:34:26 -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++;
}
if (count >= cantidad)
{
break;
}
2025-11-18 14:34:26 -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-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
.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;
}
}) ?? 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
}
}
}