436 lines
22 KiB
C#
436 lines
22 KiB
C#
// 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
|
|
public class GeminiRequest { [JsonPropertyName("contents")] public Content[] Contents { get; set; } = default!; }
|
|
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!; }
|
|
|
|
namespace ChatbotApi.Controllers
|
|
{
|
|
[ApiController]
|
|
[Route("api/[controller]")]
|
|
public class ChatController : ControllerBase
|
|
{
|
|
private readonly string _apiUrl;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly IServiceProvider _serviceProvider; // Para crear un scope de DB
|
|
private readonly ILogger<ChatController> _logger;
|
|
private static readonly HttpClient _httpClient = new HttpClient();
|
|
private static readonly string _knowledgeCacheKey = "KnowledgeBase"; // Clave única para nuestra caché
|
|
|
|
private static readonly string _siteUrl = "https://www.eldia.com/";
|
|
private static readonly string[] PrefijosAQuitar = { "VIDEO.- ", "VIDEO. ", "FOTOS.- ", "FOTOS. " };
|
|
|
|
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}";
|
|
}
|
|
|
|
[HttpPost("stream-message")]
|
|
[EnableRateLimiting("fixed")]
|
|
public async IAsyncEnumerable<string> StreamMessage(
|
|
[FromBody] ChatRequest request,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
// --- FASE 1: Validación y Preparación ---
|
|
if (string.IsNullOrWhiteSpace(request?.Message))
|
|
{
|
|
yield return "Error: No he recibido ningún mensaje.";
|
|
yield break;
|
|
}
|
|
|
|
string userMessage = request.Message;
|
|
string lowerUserMessage = userMessage.ToLowerInvariant();
|
|
string context;
|
|
string? errorMessage = null; // Variable para almacenar el mensaje de error
|
|
|
|
try
|
|
{
|
|
var knowledgeBase = await GetKnowledgeAsync();
|
|
string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase);
|
|
context = dbContext ?? await GetWebsiteNewsAsync(_siteUrl, 15);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error al obtener el contexto para el streaming.");
|
|
errorMessage = "Error: No se pudo obtener la información de contexto.";
|
|
context = string.Empty; // Aseguramos que el contexto no sea nulo
|
|
}
|
|
|
|
// Si hubo un error en la fase anterior, lo devolvemos
|
|
if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
yield return errorMessage;
|
|
yield break;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(context))
|
|
{
|
|
yield return "Error: No pude obtener información para responder a tu pregunta.";
|
|
yield break;
|
|
}
|
|
|
|
// --- FASE 2: Configuración de la Conexión ---
|
|
Stream? responseStream = null;
|
|
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.");
|
|
promptBuilder.AppendLine(GetContextFromDb(lowerUserMessage, await GetKnowledgeAsync()) != null ? "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.). No hables de noticias." : "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO DEL SITIO WEB' que contiene una lista de noticias. Si el usuario pide la URL de una noticia, DEBES proporcionarla en formato Markdown: '[texto del enlace](URL)'.");
|
|
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 } } } } };
|
|
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 de Gemini (Streaming) devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent);
|
|
throw new HttpRequestException("La API de Gemini devolvió un error.");
|
|
}
|
|
|
|
responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
_logger.LogInformation("La operación fue cancelada por el cliente durante la configuración del stream.");
|
|
yield break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error inesperado durante la configuración del stream.");
|
|
errorMessage = "Error: Lo siento, estoy teniendo un problema técnico.";
|
|
}
|
|
|
|
// Devolvemos el error de la fase de conexión si ocurrió
|
|
if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
yield return errorMessage;
|
|
yield break;
|
|
}
|
|
|
|
// --- FASE 3: Lectura y Devolución del Stream ---
|
|
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);
|
|
string? chunk = null; // 1. Declaramos la variable 'chunk' fuera del try.
|
|
|
|
try
|
|
{
|
|
// 2. El bloque try solo se encarga de la deserialización.
|
|
var geminiResponse = JsonSerializer.Deserialize<GeminiStreamingResponse>(jsonString);
|
|
chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
// 3. Si falla, lo registramos y pasamos al siguiente fragmento.
|
|
_logger.LogWarning(ex, "No se pudo deserializar un chunk del stream: {JsonChunk}", jsonString);
|
|
continue;
|
|
}
|
|
|
|
// 4. El yield return ahora está fuera del bloque try-catch.
|
|
if (chunk != null)
|
|
{
|
|
fullBotReply.Append(chunk);
|
|
yield return chunk;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- FASE 4: Guardado final ---
|
|
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.");
|
|
}
|
|
}
|
|
|
|
[HttpPost("message")]
|
|
[EnableRateLimiting("fixed")]
|
|
public async Task<IActionResult> PostMessage([FromBody] ChatRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request?.Message))
|
|
{
|
|
return BadRequest(new ChatResponse { Reply = "No he recibido ningún mensaje." });
|
|
}
|
|
|
|
try
|
|
{
|
|
string userMessage = request.Message;
|
|
string lowerUserMessage = userMessage.ToLowerInvariant();
|
|
string context;
|
|
string promptInstructions;
|
|
|
|
// 1. Obtenemos el conocimiento desde nuestro nuevo método de caché
|
|
var knowledgeBase = await GetKnowledgeAsync();
|
|
string? dbContext = GetContextFromDb(lowerUserMessage, knowledgeBase);
|
|
|
|
if (dbContext != null)
|
|
{
|
|
context = dbContext;
|
|
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.). No hables de noticias.";
|
|
}
|
|
// 2. Si no encontramos nada en la base de conocimiento, buscamos en las noticias.
|
|
else
|
|
{
|
|
context = await GetWebsiteNewsAsync(_siteUrl, 15);
|
|
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO DEL SITIO WEB' que contiene una lista de noticias. Si el usuario pide la URL de una noticia, DEBES proporcionarla en formato Markdown: '[texto del enlace](URL)'.";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(context))
|
|
{
|
|
return StatusCode(500, new ChatResponse { Reply = "No pude obtener información para responder a tu pregunta." });
|
|
}
|
|
|
|
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. Debes hablar en español 'Rioplatense'.");
|
|
promptBuilder.AppendLine(promptInstructions); // Instrucciones dinámicas
|
|
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 requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
|
|
var response = await _httpClient.PostAsJsonAsync(_apiUrl, requestData);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync();
|
|
_logger.LogWarning("La API de Gemini devolvió un error. Status: {StatusCode}, Content: {ErrorContent}", response.StatusCode, errorContent);
|
|
return StatusCode(500, new ChatResponse { Reply = "Hubo un error al comunicarse con el asistente de IA." });
|
|
}
|
|
|
|
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
|
string botReply = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "Lo siento, no pude procesar una respuesta.";
|
|
|
|
_logger.LogInformation($"[DEBUG] Respuesta de Gemini: '{botReply}'");
|
|
|
|
try
|
|
{
|
|
// Usamos el IServiceProvider para crear un scope de DbContext temporal y seguro.
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
// Renombramos la variable para evitar conflictos de ámbito.
|
|
var scopedDbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
|
|
|
var logEntry = new ConversacionLog
|
|
{
|
|
UsuarioMensaje = userMessage,
|
|
BotRespuesta = botReply,
|
|
Fecha = DateTime.UtcNow
|
|
};
|
|
|
|
scopedDbContext.ConversacionLogs.Add(logEntry);
|
|
await scopedDbContext.SaveChangesAsync();
|
|
}
|
|
}
|
|
catch (Exception logEx)
|
|
{
|
|
_logger.LogError(logEx, "Error al intentar guardar el log de la conversación en la base de datos.");
|
|
}
|
|
return Ok(new ChatResponse { Reply = botReply });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error inesperado al procesar el mensaje del usuario.");
|
|
return StatusCode(500, new ChatResponse { Reply = "Lo siento, estoy teniendo un problema técnico." });
|
|
}
|
|
}
|
|
|
|
// Método para buscar contexto en la caché de la DB
|
|
|
|
private string? GetContextFromDb(string lowerUserMessage, Dictionary<string, string> knowledgeBase)
|
|
{
|
|
// 1. Definimos una lista de palabras comunes a ignorar para hacer la búsqueda más precisa.
|
|
var stopWords = new HashSet<string>
|
|
{
|
|
"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "a", "ante",
|
|
"con", "contra", "desde", "en", "entre", "hacia", "hasta", "para", "por", "segun",
|
|
"sin", "sobre", "tras", "y", "o", "que", "cual", "cuales", "como", "cuando", "donde",
|
|
"quien", "es", "soy", "estoy", "mi", "mis", "quiero", "necesito", "saber", "dime", "dame",
|
|
"informacion", "acerca", "mas"
|
|
};
|
|
|
|
// 2. Separamos el mensaje del usuario en palabras individuales, eliminando las stop words.
|
|
var userWords = lowerUserMessage
|
|
.Split(new[] { ' ', ',', '.', '?', '!', '¿', '¡' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Where(word => !stopWords.Contains(word))
|
|
.ToHashSet(); // Usamos un HashSet para búsquedas de palabras muy rápidas.
|
|
|
|
if (!userWords.Any())
|
|
{
|
|
return null; // Si solo había stop words, no buscamos nada.
|
|
}
|
|
|
|
// 3. Iteramos sobre la base de conocimiento para encontrar la mejor coincidencia.
|
|
foreach (var kvp in knowledgeBase)
|
|
{
|
|
// Separamos las claves compuestas ("contacto_telefono" -> ["contacto", "telefono"])
|
|
var keywords = kvp.Key.Split('_');
|
|
|
|
// 4. Comprobamos si ALGUNA de las palabras clave de la BD coincide con ALGUNA de las palabras del usuario.
|
|
if (keywords.Any(k => userWords.Contains(k)))
|
|
{
|
|
_logger.LogInformation("Contexto encontrado por coincidencia de palabra clave. Clave de BD: '{DbKey}', Palabra de usuario encontrada: '{MatchedWord}'",
|
|
kvp.Key,
|
|
string.Join(", ", keywords.Where(k => userWords.Contains(k))));
|
|
|
|
return kvp.Value; // Devolvemos el valor correspondiente a la primera clave que coincida.
|
|
}
|
|
}
|
|
|
|
return null; // No se encontró ninguna coincidencia.
|
|
}
|
|
|
|
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
|
|
{
|
|
try
|
|
{
|
|
var web = new HtmlWeb();
|
|
var doc = await web.LoadFromWebAsync(url);
|
|
var nodosDeEnlace = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]/a[@href]");
|
|
|
|
if (nodosDeEnlace == null || !nodosDeEnlace.Any()) return string.Empty;
|
|
|
|
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 nodoEnlace in nodosDeEnlace)
|
|
{
|
|
var urlRelativa = nodoEnlace.GetAttributeValue("href", string.Empty);
|
|
if (string.IsNullOrEmpty(urlRelativa) || urlRelativa == "#" || urlsProcesadas.Contains(urlRelativa)) continue;
|
|
|
|
var nodoTitulo = nodoEnlace.SelectSingleNode(".//h1 | .//h2");
|
|
if (nodoTitulo != null)
|
|
{
|
|
var textoLimpio = CleanTitleText(nodoTitulo.InnerText);
|
|
var urlCompleta = urlRelativa.StartsWith("/") ? new Uri(new Uri(url), urlRelativa).ToString() : urlRelativa;
|
|
contextBuilder.AppendLine($"- Título: \"{textoLimpio}\", URL: {urlCompleta}");
|
|
urlsProcesadas.Add(urlRelativa);
|
|
count++;
|
|
}
|
|
if (count >= cantidad) break;
|
|
}
|
|
return contextBuilder.ToString();
|
|
}
|
|
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;
|
|
}
|
|
private async Task<Dictionary<string, string>> GetKnowledgeAsync()
|
|
{
|
|
// Intenta obtener el diccionario de la caché.
|
|
// Si no existe, el segundo argumento (la función factory) se ejecutará.
|
|
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry =>
|
|
{
|
|
_logger.LogInformation("La caché de conocimiento no existe o ha expirado. Recargando desde la base de datos...");
|
|
|
|
// Establecemos un tiempo de expiración para la caché.
|
|
// Después de 5 minutos, la caché se considerará inválida y se recargará en la siguiente petición.
|
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
|
|
|
|
// Usamos IServiceProvider para crear un 'scope' de servicios temporal.
|
|
// Esto es necesario porque el DbContext tiene un tiempo de vida por petición (scoped),
|
|
// y esta función factory podría ejecutarse fuera de ese contexto.
|
|
using (var scope = _serviceProvider.CreateScope())
|
|
{
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
|
var knowledge = await dbContext.ContextoItems
|
|
.AsNoTracking() // Mejora el rendimiento para consultas de solo lectura
|
|
.ToDictionaryAsync(item => item.Clave, item => item.Valor);
|
|
|
|
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
|
|
return knowledge;
|
|
}
|
|
}) ?? new Dictionary<string, string>(); // Si todo falla, devuelve un diccionario vacío.
|
|
}
|
|
}
|
|
} |