Feat Gestion de Fuentes URLs

This commit is contained in:
2025-11-21 11:20:44 -03:00
parent 1a46f15ec1
commit 2e353cbb8c
9 changed files with 832 additions and 77 deletions

View File

@@ -86,5 +86,65 @@ namespace ChatbotApi.Controllers
.ToListAsync();
return Ok(logs);
}
// ENDPOINTS PARA FUENTES DE CONTEXTO (URLs)
[HttpGet("fuentes")]
public async Task<IActionResult> GetAllFuentes()
{
var fuentes = await _context.FuentesDeContexto.OrderBy(f => f.Nombre).ToListAsync();
return Ok(fuentes);
}
[HttpPost("fuentes")]
public async Task<IActionResult> CreateFuente([FromBody] FuenteContexto fuente)
{
_context.FuentesDeContexto.Add(fuente);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetAllFuentes), new { id = fuente.Id }, fuente);
}
[HttpPut("fuentes/{id}")]
public async Task<IActionResult> UpdateFuente(int id, [FromBody] FuenteContexto fuente)
{
if (id != fuente.Id)
{
return BadRequest();
}
_context.Entry(fuente).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!_context.FuentesDeContexto.Any(e => e.Id == id))
{
return NotFound();
}
else
{
throw;
}
}
return NoContent();
}
[HttpDelete("fuentes/{id}")]
public async Task<IActionResult> DeleteFuente(int id)
{
var fuente = await _context.FuentesDeContexto.FindAsync(id);
if (fuente == null)
{
return NotFound();
}
_context.FuentesDeContexto.Remove(fuente);
await _context.SaveChangesAsync();
return NoContent();
}
}
}

View File

@@ -33,7 +33,7 @@ public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[
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 }
public enum IntentType { Article, Database, Homepage, ExternalSource }
namespace ChatbotApi.Controllers
{
@@ -102,14 +102,34 @@ namespace ChatbotApi.Controllers
}
}
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary)
private async Task<(IntentType intent, string? data)> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary, Dictionary<string, ContextoItem> knowledgeBase)
{
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("Tu tarea es actuar como un router de intenciones...");
promptBuilder.AppendLine("- [ARTICULO_ACTUAL]");
promptBuilder.AppendLine("- [NOTICIAS_PORTADA]");
promptBuilder.AppendLine("- [BASE_DE_DATOS:CLAVE_SELECCIONADA]");
// --- LÓGICA DINÁMICA ---
List<FuenteContexto> fuentesExternas;
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
fuentesExternas = await dbContext.FuentesDeContexto.Where(f => f.Activo).ToListAsync();
}
foreach (var fuente in fuentesExternas)
{
promptBuilder.AppendLine($"[FUENTE_EXTERNA:{fuente.Url}]: Úsala si la pregunta trata sobre: {fuente.DescripcionParaIA}");
}
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.");
promptBuilder.AppendLine("[ARTICULO_ACTUAL]: Úsala si la pregunta es una continuación directa sobre el artículo que se está discutiendo.");
promptBuilder.AppendLine("[NOTICIAS_PORTADA]: Úsala para preguntas generales sobre noticias actuales o eventos.");
promptBuilder.AppendLine("[BASE_DE_DATOS:CLAVE_SELECCIONADA]: Úsala para preguntas sobre información específica del diario. DEBES reemplazar 'CLAVE_SELECCIONADA' con la clave más relevante de la siguiente lista:");
var dbKeys = string.Join(", ", knowledgeBase.Keys);
promptBuilder.AppendLine($" - Claves disponibles: {dbKeys}");
if (!string.IsNullOrWhiteSpace(conversationSummary))
{
@@ -119,13 +139,13 @@ namespace ChatbotApi.Controllers
if (!string.IsNullOrEmpty(activeArticleContent))
{
promptBuilder.AppendLine("\n--- CONVERSACIÓN ACTUAL (Contexto del artículo) ---");
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--- HERRAMIENTA SELECCIONADA ---");
promptBuilder.AppendLine("\n--- HERRAMIENTA Y DATOS SELECCIONADOS ---");
var finalPrompt = promptBuilder.ToString();
var requestData = new GeminiRequest { Contents = new[] { new Content { Parts = new[] { new Part { Text = finalPrompt } } } } };
@@ -134,24 +154,36 @@ namespace ChatbotApi.Controllers
try
{
var response = await _httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
if (!response.IsSuccessStatusCode) return IntentType.Homepage;
if (!response.IsSuccessStatusCode) return (IntentType.Homepage, null);
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
_logger.LogInformation("Intención detectada: {Intent}", responseText);
_logger.LogInformation("Intención y datos detectados: {Response}", responseText);
if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article;
if (responseText.Contains("BASE_DE_DATOS")) return IntentType.Database;
return IntentType.Homepage;
if (responseText.Contains("ARTICULO_ACTUAL")) return (IntentType.Article, null);
if (responseText.Contains("NOTICIAS_PORTADA")) return (IntentType.Homepage, null);
if (responseText.Contains("BASE_DE_DATOS:"))
{
var key = responseText.Split(new[] { ':' }, 2)[1].TrimEnd(']');
return (IntentType.Database, key);
}
if (responseText.Contains("FUENTE_EXTERNA:"))
{
var url = responseText.Split(new[] { ':' }, 2, StringSplitOptions.None)[1].TrimEnd(']');
return (IntentType.ExternalSource, url); // Necesitaremos un nuevo IntentType
}
return (IntentType.Homepage, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en GetIntentAsync. Usando fallback a Homepage.");
return IntentType.Homepage;
return (IntentType.Homepage, null);
}
}
[HttpPost("stream-message")]
[EnableRateLimiting("fixed")]
public async IAsyncEnumerable<string> StreamMessage(
@@ -178,8 +210,11 @@ namespace ChatbotApi.Controllers
articleContext = await GetArticleContentAsync(request.ContextUrl);
}
// Le pasamos el resumen al router de intenciones
intent = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary);
var knowledgeBase = await GetKnowledgeAsync();
// --- CORRECCIÓN 2: El código que llama a GetIntentAsync debe esperar una tupla ---
var (detectedIntent, intentData) = await GetIntentAsync(userMessage, articleContext, request.ConversationSummary, knowledgeBase);
intent = detectedIntent;
switch (intent)
{
@@ -190,17 +225,38 @@ namespace ChatbotApi.Controllers
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.).";
// --- CORRECCIÓN 3: La lógica aquí debe manejar la clave recibida ---
_logger.LogInformation("Ejecutando intención: Base de Datos con clave '{Key}'.", intentData);
if (intentData != null && knowledgeBase.TryGetValue(intentData, out var dbItem))
{
// Ahora dbItem es un objeto ContextoItem, no un string.
var dbContextBuilder = new StringBuilder();
dbContextBuilder.AppendLine("Aquí tienes la información solicitada:");
dbContextBuilder.AppendLine($"- PREGUNTA: {dbItem.Descripcion}");
dbContextBuilder.AppendLine($" RESPUESTA: {dbItem.Valor}");
context = dbContextBuilder.ToString();
}
else
{
context = "No se encontró información relevante para la clave solicitada.";
_logger.LogWarning("La clave '{Key}' devuelta por la IA no es válida.", intentData);
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene la pregunta y respuesta encontrada.";
break;
case IntentType.Homepage:
default:
_logger.LogInformation("Ejecutando intención: Noticias de Portada.");
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)'.";
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.";
break;
case IntentType.ExternalSource:
_logger.LogInformation("Ejecutando intención: Fuente Externa con URL '{Url}'.", intentData);
if (!string.IsNullOrEmpty(intentData))
{
context = await ScrapeUrlContentAsync(intentData);
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote ESTRICTA Y ÚNICAMENTE en el 'CONTEXTO' que contiene el texto de una página web.";
break;
}
}
@@ -338,55 +394,7 @@ namespace ChatbotApi.Controllers
}
}
private async Task<string?> FindBestDbItemAsync(string userMessage, string? conversationSummary, Dictionary<string, string> knowledgeBase)
{
if (knowledgeBase == null || !knowledgeBase.Any()) return null;
var availableKeys = string.Join(", ", knowledgeBase.Keys);
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 ---");
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 bestKey = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
if (bestKey != null && knowledgeBase.TryGetValue(bestKey, out var contextValue))
{
_logger.LogInformation("Búsqueda en DB por IA: La pregunta '{userMessage}' se asoció con la clave '{bestKey}'.", userMessage, bestKey);
return contextValue;
}
_logger.LogWarning("Búsqueda en DB por IA: La clave devuelta '{bestKey}' no es válida.", bestKey);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en FindBestDbItemAsync.");
return null;
}
}
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad)
{
@@ -462,7 +470,7 @@ namespace ChatbotApi.Controllers
return textoDecodificado;
}
private async Task<Dictionary<string, string>> GetKnowledgeAsync()
private async Task<Dictionary<string, ContextoItem>> GetKnowledgeAsync()
{
return await _cache.GetOrCreateAsync(_knowledgeCacheKey, async entry =>
{
@@ -471,13 +479,14 @@ namespace ChatbotApi.Controllers
using (var scope = _serviceProvider.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AppContexto>();
// Usamos ToDictionaryAsync para obtener el objeto ContextoItem completo.
var knowledge = await dbContext.ContextoItems
.AsNoTracking()
.ToDictionaryAsync(item => item.Clave, item => item.Valor);
.ToDictionaryAsync(item => item.Clave, item => item);
_logger.LogInformation($"Caché actualizada con {knowledge.Count} items.");
return knowledge;
}
}) ?? new Dictionary<string, string>();
}) ?? new Dictionary<string, ContextoItem>();
}
private async Task<string?> GetArticleContentAsync(string url)
@@ -514,5 +523,28 @@ namespace ChatbotApi.Controllers
return null;
}
}
private async Task<string> ScrapeUrlContentAsync(string url)
{
// Usamos la URL como clave de caché para no scrapear la misma página una y otra vez.
return await _cache.GetOrCreateAsync(url, async entry =>
{
_logger.LogInformation("Contenido de {Url} no encontrado en caché. Scrapeando...", url);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Cachear por 1 hora
var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url);
// Selector genérico que intenta obtener el contenido principal
// Esto puede necesitar ajustes dependiendo de la estructura de las páginas
var mainContentNode = doc.DocumentNode.SelectSingleNode("//main") ?? doc.DocumentNode.SelectSingleNode("//body");
if (mainContentNode == null) return string.Empty;
// Podríamos hacer esto mucho más inteligente, buscando <p>, <h2>, <li>, etc.
// pero para empezar, InnerText es un buen punto de partida.
return WebUtility.HtmlDecode(mainContentNode.InnerText);
}) ?? string.Empty;
}
}
}