Feat Gestion de Fuentes URLs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user