Try: Reducción de llamadas API por mensaje (-67% de costo)
- 3 → 1 llamada API por mensaje (-66,6% de costo) - Métodos GetIntentAsync y UpdateConversationSummaryAsync eliminados - Prompt unificado con respuesta JSON estructurada
This commit is contained in:
@@ -48,6 +48,14 @@ namespace ChatbotApi.Services
|
|||||||
_apiUrl = $"{baseUrl}{apiKey}";
|
_apiUrl = $"{baseUrl}{apiKey}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response model for structured JSON from Gemini
|
||||||
|
private class GeminiStructuredResponse
|
||||||
|
{
|
||||||
|
public string intent { get; set; } = "NOTICIAS_PORTADA";
|
||||||
|
public string reply { get; set; } = "";
|
||||||
|
public string summary { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<string> StreamMessageAsync(ChatRequest request, [EnumeratorCancellation] CancellationToken cancellationToken)
|
public async IAsyncEnumerable<string> StreamMessageAsync(ChatRequest request, [EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request?.Message))
|
if (string.IsNullOrWhiteSpace(request?.Message))
|
||||||
@@ -58,17 +66,16 @@ namespace ChatbotApi.Services
|
|||||||
|
|
||||||
string safeUserMessage = SanitizeInput(request.Message);
|
string safeUserMessage = SanitizeInput(request.Message);
|
||||||
string context = "";
|
string context = "";
|
||||||
string promptInstructions = "";
|
|
||||||
string? articleContext = null;
|
string? articleContext = null;
|
||||||
string? errorMessage = null;
|
string? errorMessage = null;
|
||||||
IntentType intent = IntentType.Homepage;
|
|
||||||
|
|
||||||
// [OPTIMIZACIÓN] Pre-carga de prompts del sistema en paralelo
|
// Pre-carga de prompts del sistema en paralelo
|
||||||
var systemPromptsTask = GetActiveSystemPromptsAsync();
|
var systemPromptsTask = GetActiveSystemPromptsAsync();
|
||||||
Task<string?>? articleTask = null;
|
Task<string?>? articleTask = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Load article if URL provided
|
||||||
if (!string.IsNullOrEmpty(request.ContextUrl) && await UrlSecurity.IsSafeUrlAsync(request.ContextUrl))
|
if (!string.IsNullOrEmpty(request.ContextUrl) && await UrlSecurity.IsSafeUrlAsync(request.ContextUrl))
|
||||||
{
|
{
|
||||||
articleTask = GetArticleContentAsync(request.ContextUrl);
|
articleTask = GetArticleContentAsync(request.ContextUrl);
|
||||||
@@ -76,151 +83,142 @@ namespace ChatbotApi.Services
|
|||||||
|
|
||||||
if (articleTask != null) articleContext = await articleTask;
|
if (articleTask != null) articleContext = await articleTask;
|
||||||
|
|
||||||
intent = await GetIntentAsync(safeUserMessage, articleContext, request.ConversationSummary);
|
// Build context based on heuristics
|
||||||
|
if (!string.IsNullOrEmpty(articleContext))
|
||||||
// [FIX] Si la intención es 'Artículo' pero no hay contexto (el usuario pregunta sobre un tema específico sin abrir una nota),
|
|
||||||
// asumimos que quiere BUSCAR en la portada.
|
|
||||||
if (intent == IntentType.Article && string.IsNullOrEmpty(articleContext))
|
|
||||||
{
|
{
|
||||||
intent = IntentType.Homepage;
|
context = articleContext;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var articles = await GetWebsiteNewsAsync(_siteUrl, 50);
|
||||||
|
|
||||||
|
if (request.ShownArticles != null && request.ShownArticles.Any())
|
||||||
|
{
|
||||||
|
articles = articles.Where(a => !request.ShownArticles.Contains(a.Url)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Búsqueda Híbrida: local + AI fallback
|
||||||
|
var bestMatch = FindBestMatchingArticleLocal(safeUserMessage, articles);
|
||||||
|
|
||||||
|
if (bestMatch == null)
|
||||||
|
{
|
||||||
|
bestMatch = await FindBestMatchingArticleAIAsync(safeUserMessage, articles, request.ConversationSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestMatch != null && await UrlSecurity.IsSafeUrlAsync(bestMatch.Url))
|
||||||
|
{
|
||||||
|
string rawContent = await GetArticleContentAsync(bestMatch.Url) ?? "";
|
||||||
|
context = $"ARTÍCULO ENCONTRADO: {bestMatch.Title}\nURL: {bestMatch.Url}\n\nCONTENIDO:\n{SanitizeInput(rawContent)}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("NOTICIAS DISPONIBLES:");
|
||||||
|
foreach (var article in articles.Take(15))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- {article.Title} ({article.Url})");
|
||||||
|
}
|
||||||
|
context = sb.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (intent)
|
// Add knowledge base if available
|
||||||
|
var knowledgeItems = await GetKnowledgeItemsAsync();
|
||||||
|
if (knowledgeItems.Any())
|
||||||
{
|
{
|
||||||
case IntentType.Article:
|
var kbBuilder = new StringBuilder("\n\nBASE DE CONOCIMIENTO:");
|
||||||
context = articleContext ?? "No se pudo cargar el artículo.";
|
foreach (var item in knowledgeItems.Values)
|
||||||
promptInstructions = "Responde la pregunta dentro de <pregunta_usuario> basándote ESTRICTA Y ÚNICAMENTE en la información dentro de <contexto>.";
|
{
|
||||||
break;
|
kbBuilder.AppendLine($"\n- {item.Descripcion}: {item.Valor}");
|
||||||
|
}
|
||||||
case IntentType.KnowledgeBase:
|
context += kbBuilder.ToString();
|
||||||
var contextBuilder = new StringBuilder();
|
|
||||||
|
|
||||||
// [OPTIMIZACIÓN] Recolección de conocimiento en paralelo
|
|
||||||
var knowledgeTask = GetKnowledgeItemsAsync();
|
|
||||||
var fuentesTask = GetFuentesDeContextoAsync();
|
|
||||||
await Task.WhenAll(knowledgeTask, fuentesTask);
|
|
||||||
|
|
||||||
foreach (var item in knowledgeTask.Result.Values)
|
|
||||||
{
|
|
||||||
contextBuilder.AppendLine($"- TEMA: {item.Descripcion}\n INFORMACIÓN: {item.Valor}");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var fuente in fuentesTask.Result)
|
|
||||||
{
|
|
||||||
if (await UrlSecurity.IsSafeUrlAsync(fuente.Url))
|
|
||||||
{
|
|
||||||
contextBuilder.AppendLine($"\n--- {fuente.Nombre} ---");
|
|
||||||
string scrapedContent = await ScrapeUrlContentAsync(fuente);
|
|
||||||
contextBuilder.AppendLine(SanitizeInput(scrapedContent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context = contextBuilder.ToString();
|
|
||||||
promptInstructions = "Responde basándote ESTRICTA Y ÚNICAMENTE en la información proporcionada en <contexto>.";
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// No es necesario hacer scraping si solo vinculamos a la portada,
|
|
||||||
// pero la lógica mantiene el scraping de 50 items aquí.
|
|
||||||
// Podría optimizarse más, pero el scraping es rápido comparado con el LLM.
|
|
||||||
var articles = await GetWebsiteNewsAsync(_siteUrl, 50);
|
|
||||||
|
|
||||||
if (request.ShownArticles != null && request.ShownArticles.Any())
|
|
||||||
{
|
|
||||||
articles = articles
|
|
||||||
.Where(a => !request.ShownArticles.Contains(a.Url))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// [OPTIMIZACIÓN] Búsqueda Híbrida: Intentamos localmente (rápido), si falla usamos IA (inteligente)
|
|
||||||
var bestMatch = FindBestMatchingArticleLocal(safeUserMessage, articles);
|
|
||||||
|
|
||||||
if (bestMatch == null)
|
|
||||||
{
|
|
||||||
bestMatch = await FindBestMatchingArticleAIAsync(safeUserMessage, articles, request.ConversationSummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestMatch != null)
|
|
||||||
{
|
|
||||||
if (await UrlSecurity.IsSafeUrlAsync(bestMatch.Url))
|
|
||||||
{
|
|
||||||
string rawContent = await GetArticleContentAsync(bestMatch.Url) ?? "";
|
|
||||||
context = SanitizeInput(rawContent);
|
|
||||||
promptInstructions = $"La pregunta es sobre el artículo '{bestMatch.Title}'. Responde con un resumen conciso y ofrece el enlace: [{bestMatch.Title}]({bestMatch.Url}).";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// [OPTIMIZACIÓN] Limitamos a las 15 primeras para no saturar el contexto
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
foreach (var article in articles.Take(15)) sb.AppendLine($"- {article.Title} ({article.Url})");
|
|
||||||
context = sb.ToString();
|
|
||||||
promptInstructions = "Responde la pregunta del usuario. Si la pregunta es sobre algo mencionado en el <historial_conversacion>, responde basándote en eso (por ejemplo, si pregunta 'dónde puedo leerla', proporciona el enlace mencionado anteriormente). Si la pregunta es sobre noticias actuales, selecciona las 3 más relevantes del <contexto>, escribe una frase breve para cada una e INCLUYE el enlace con formato [Título](URL).";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error procesando intención.");
|
_logger.LogError(ex, "Error construyendo contexto.");
|
||||||
errorMessage = "Lo siento, hubo un problema técnico procesando tu solicitud.";
|
errorMessage = "Lo siento, hubo un problema técnico procesando tu solicitud.";
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return $"INTENT::{intent}";
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(errorMessage))
|
if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
yield return errorMessage;
|
yield return errorMessage;
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream? responseStream = null;
|
// ========== UNIFIED API CALL ==========
|
||||||
var fullBotReply = new StringBuilder();
|
|
||||||
var httpClient = _httpClientFactory.CreateClient();
|
var httpClient = _httpClientFactory.CreateClient();
|
||||||
httpClient.Timeout = TimeSpan.FromSeconds(30);
|
httpClient.Timeout = TimeSpan.FromSeconds(45);
|
||||||
|
|
||||||
|
string? jsonText = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var promptBuilder = new StringBuilder();
|
|
||||||
var systemInstructions = !string.IsNullOrWhiteSpace(request.SystemPromptOverride)
|
var systemInstructions = !string.IsNullOrWhiteSpace(request.SystemPromptOverride)
|
||||||
? request.SystemPromptOverride
|
? request.SystemPromptOverride
|
||||||
: await systemPromptsTask; // Esperar tarea precargada
|
: await systemPromptsTask;
|
||||||
|
|
||||||
|
// Build unified meta-prompt
|
||||||
|
var promptBuilder = new StringBuilder();
|
||||||
|
|
||||||
promptBuilder.AppendLine("<instrucciones_sistema>");
|
promptBuilder.AppendLine("<instrucciones_sistema>");
|
||||||
promptBuilder.AppendLine("Eres DiaBot, asistente virtual de El Día (La Plata, Argentina).");
|
promptBuilder.AppendLine("Eres DiaBot, asistente virtual de El Día (La Plata, Argentina).");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
|
promptBuilder.AppendLine("FORMATO DE RESPUESTA:");
|
||||||
|
promptBuilder.AppendLine("Debes responder en formato JSON con esta estructura EXACTA:");
|
||||||
|
promptBuilder.AppendLine("{\"intent\": \"...\", \"reply\": \"...\", \"summary\": \"...\"}");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
|
|
||||||
|
promptBuilder.AppendLine("INSTRUCCIONES GENERALES:");
|
||||||
promptBuilder.AppendLine(systemInstructions);
|
promptBuilder.AppendLine(systemInstructions);
|
||||||
promptBuilder.AppendLine("IMPORTANTE:");
|
promptBuilder.AppendLine("- NO uses formatos de email/carta ('Estimado/a', 'Atentamente')");
|
||||||
promptBuilder.AppendLine("- NO uses formatos de email/carta ('Estimado/a', 'Atentamente').");
|
promptBuilder.AppendLine("- NO saludes de nuevo si ya saludaste o si la pregunta es directa");
|
||||||
promptBuilder.AppendLine("- NO saludes de nuevo si ya saludaste o si la pregunta es directa, ve al grano.");
|
promptBuilder.AppendLine("- Sé conciso, directo y natural");
|
||||||
promptBuilder.AppendLine("- Sé conciso, directo y natural.");
|
promptBuilder.AppendLine();
|
||||||
promptBuilder.AppendLine("- Si el usuario pregunta '¿algo más?' o '¿qué más?', asume que pide más noticias de la portada y no saludes.");
|
|
||||||
promptBuilder.AppendLine(promptInstructions);
|
promptBuilder.AppendLine("--- REGLAS PARA CADA CAMPO JSON ---");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
try
|
promptBuilder.AppendLine("1. 'intent': Clasifica la intención usando SOLO uno de estos valores:");
|
||||||
{
|
promptBuilder.AppendLine(" - \"ARTICULO_ACTUAL\": Si la pregunta es sobre el tema del artículo en <contexto>");
|
||||||
var timeInfo = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"));
|
promptBuilder.AppendLine(" - \"BASE_DE_CONOCIMIENTO\": Para preguntas sobre 'El Día' como empresa/organización");
|
||||||
promptBuilder.AppendLine($"Fecha y hora actual: {timeInfo:dd/MM/yyyy HH:mm}");
|
promptBuilder.AppendLine(" - \"NOTICIAS_PORTADA\": Para todo lo demás (este es el default si dudas)");
|
||||||
}
|
promptBuilder.AppendLine();
|
||||||
catch { }
|
|
||||||
|
promptBuilder.AppendLine("2. 'reply': Tu respuesta en texto Markdown para el usuario.");
|
||||||
|
promptBuilder.AppendLine(" - Si es un artículo específico: Resume brevemente e INCLUYE el enlace [Título](URL)");
|
||||||
|
promptBuilder.AppendLine(" - Si son noticias generales: Selecciona las 3 más relevantes, breve frase c/u + enlace");
|
||||||
|
promptBuilder.AppendLine(" - Si la pregunta refiere a algo del <historial_conversacion>, úsalo (ej: 'dónde leerla' → dale el link)");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
|
|
||||||
|
promptBuilder.AppendLine("3. 'summary': Actualiza el historial de conversación.");
|
||||||
|
promptBuilder.AppendLine(" - Resume el intercambio actual (pregunta + respuesta) en 1-2 líneas");
|
||||||
|
promptBuilder.AppendLine(" - Integra con el <historial_conversacion> previo si existe");
|
||||||
|
promptBuilder.AppendLine(" - Máximo 200 palabras para el resumen completo");
|
||||||
promptBuilder.AppendLine("</instrucciones_sistema>");
|
promptBuilder.AppendLine("</instrucciones_sistema>");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
|
|
||||||
// Incluir historial de conversación para referencias contextuales
|
// Conversation history
|
||||||
if (!string.IsNullOrWhiteSpace(request.ConversationSummary))
|
if (!string.IsNullOrWhiteSpace(request.ConversationSummary))
|
||||||
{
|
{
|
||||||
promptBuilder.AppendLine("<historial_conversacion>");
|
promptBuilder.AppendLine("<historial_conversacion>");
|
||||||
promptBuilder.AppendLine(SanitizeInput(request.ConversationSummary));
|
promptBuilder.AppendLine(SanitizeInput(request.ConversationSummary));
|
||||||
promptBuilder.AppendLine("</historial_conversacion>");
|
promptBuilder.AppendLine("</historial_conversacion>");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Context
|
||||||
promptBuilder.AppendLine("<contexto>");
|
promptBuilder.AppendLine("<contexto>");
|
||||||
promptBuilder.AppendLine(context);
|
promptBuilder.AppendLine(context);
|
||||||
promptBuilder.AppendLine("</contexto>");
|
promptBuilder.AppendLine("</contexto>");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
|
|
||||||
|
// User question
|
||||||
promptBuilder.AppendLine("<pregunta_usuario>");
|
promptBuilder.AppendLine("<pregunta_usuario>");
|
||||||
promptBuilder.AppendLine(safeUserMessage);
|
promptBuilder.AppendLine(safeUserMessage);
|
||||||
promptBuilder.AppendLine("</pregunta_usuario>");
|
promptBuilder.AppendLine("</pregunta_usuario>");
|
||||||
|
promptBuilder.AppendLine();
|
||||||
promptBuilder.AppendLine("RESPUESTA:");
|
|
||||||
|
promptBuilder.AppendLine("RESPUESTA (SOLO el JSON, sin comentarios adicionales):");
|
||||||
|
|
||||||
var requestData = new GeminiRequest
|
var requestData = new GeminiRequest
|
||||||
{
|
{
|
||||||
@@ -229,24 +227,30 @@ namespace ChatbotApi.Services
|
|||||||
SafetySettings = GetDefaultSafetySettings()
|
SafetySettings = GetDefaultSafetySettings()
|
||||||
};
|
};
|
||||||
|
|
||||||
var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _apiUrl)
|
// Use non-streaming endpoint
|
||||||
{
|
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
|
||||||
Content = JsonContent.Create(requestData)
|
|
||||||
};
|
var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData, cancellationToken);
|
||||||
|
|
||||||
var response = await httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Error API Gemini: {StatusCode}", response.StatusCode);
|
_logger.LogWarning("Error API Gemini: {StatusCode}", response.StatusCode);
|
||||||
throw new HttpRequestException("Error en proveedor de IA.");
|
throw new HttpRequestException($"Error en proveedor de IA: {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>(cancellationToken: cancellationToken);
|
||||||
|
jsonText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(jsonText))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Respuesta vacía de Gemini");
|
||||||
|
errorMessage = "Lo siento, hubo un problema al procesar la respuesta.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error en stream.");
|
_logger.LogError(ex, "Error en llamada unificada a Gemini.");
|
||||||
errorMessage = "Lo siento, servicio temporalmente no disponible.";
|
errorMessage = "Lo siento, el servicio está temporalmente no disponible. Por favor, intenta de nuevo.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(errorMessage))
|
if (!string.IsNullOrEmpty(errorMessage))
|
||||||
@@ -255,64 +259,100 @@ namespace ChatbotApi.Services
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseStream != null)
|
// Parse JSON response (outside try-catch to allow yield)
|
||||||
|
GeminiStructuredResponse? apiResponse = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await using (responseStream)
|
// Extract JSON from markdown code blocks if present
|
||||||
using (var reader = new StreamReader(responseStream))
|
var jsonContent = jsonText!;
|
||||||
|
if (jsonText!.Contains("```json"))
|
||||||
{
|
{
|
||||||
string? line;
|
var startIndex = jsonText.IndexOf("```json") + 7;
|
||||||
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
|
var endIndex = jsonText.IndexOf("```", startIndex);
|
||||||
|
if (endIndex > startIndex)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue;
|
jsonContent = jsonText.Substring(startIndex, endIndex - startIndex).Trim();
|
||||||
var jsonString = line.Substring(6);
|
|
||||||
|
|
||||||
string? chunk = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var geminiResponse = JsonSerializer.Deserialize<GeminiStreamingResponse>(jsonString);
|
|
||||||
chunk = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text;
|
|
||||||
}
|
|
||||||
catch (JsonException) { continue; }
|
|
||||||
|
|
||||||
if (chunk != null)
|
|
||||||
{
|
|
||||||
fullBotReply.Append(chunk);
|
|
||||||
yield return chunk;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (jsonText.Contains("```"))
|
||||||
|
{
|
||||||
|
var startIndex = jsonText.IndexOf("```") + 3;
|
||||||
|
var endIndex = jsonText.IndexOf("```", startIndex);
|
||||||
|
if (endIndex > startIndex)
|
||||||
|
{
|
||||||
|
jsonContent = jsonText.Substring(startIndex, endIndex - startIndex).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiResponse = JsonSerializer.Deserialize<GeminiStructuredResponse>(jsonContent, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to parse Gemini JSON. Raw response: {JsonText}", jsonText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiResponse == null || string.IsNullOrEmpty(apiResponse.reply))
|
||||||
|
{
|
||||||
|
yield return "Lo siento, tuve un problema al procesar la respuesta. Por favor, intenta de nuevo.";
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send intent metadata
|
||||||
|
yield return $"INTENT::{apiResponse.intent}";
|
||||||
|
|
||||||
|
// Simulate streaming by chunking the reply
|
||||||
|
string fullReply = apiResponse.reply;
|
||||||
|
var words = fullReply.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var chunkBuilder = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
chunkBuilder.Append(word + " ");
|
||||||
|
|
||||||
|
// Send chunk every ~20 characters for smooth streaming
|
||||||
|
if (chunkBuilder.Length >= 20)
|
||||||
|
{
|
||||||
|
yield return chunkBuilder.ToString();
|
||||||
|
chunkBuilder.Clear();
|
||||||
|
await Task.Delay(30, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullBotReply.Length > 0)
|
// Send any remaining text
|
||||||
|
if (chunkBuilder.Length > 0)
|
||||||
{
|
{
|
||||||
// [OPTIMIZACIÓN] Logging "fire-and-forget" (BD)
|
yield return chunkBuilder.ToString();
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
using (var scope = _serviceProvider.CreateScope())
|
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
db.ConversacionLogs.Add(new ConversacionLog
|
|
||||||
{
|
|
||||||
UsuarioMensaje = safeUserMessage,
|
|
||||||
BotRespuesta = fullBotReply.ToString(),
|
|
||||||
Fecha = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
catch(Exception ex)
|
|
||||||
{
|
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<ChatService>>();
|
|
||||||
logger.LogError(ex, "Error in background logging");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// [IMPORTANTE] El resumen del contexto debe permanecer en primer plano para informar al cliente
|
|
||||||
var newSummary = await UpdateConversationSummaryAsync(request.ConversationSummary, safeUserMessage, fullBotReply.ToString());
|
|
||||||
yield return $"SUMMARY::{newSummary}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log conversation (fire-and-forget)
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using (var scope = _serviceProvider.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppContexto>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
db.ConversacionLogs.Add(new ConversacionLog
|
||||||
|
{
|
||||||
|
UsuarioMensaje = safeUserMessage,
|
||||||
|
BotRespuesta = fullReply,
|
||||||
|
Fecha = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<ChatService>>();
|
||||||
|
logger.LogError(ex, "Error in background logging");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send summary
|
||||||
|
yield return $"SUMMARY::{apiResponse.summary}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PRIVATE METHODS ---
|
// --- PRIVATE METHODS ---
|
||||||
@@ -350,107 +390,13 @@ namespace ChatbotApi.Services
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> UpdateConversationSummaryAsync(string? oldSummary, string userMessage, string botResponse)
|
// NOTE: UpdateConversationSummaryAsync and GetIntentAsync have been REMOVED
|
||||||
{
|
// Their functionality is now in the unified StreamMessageAsync call
|
||||||
string safeOldSummary = SanitizeInput(oldSummary ?? "Esta es una nueva conversación.");
|
|
||||||
string safeUserMsg = SanitizeInput(userMessage);
|
|
||||||
string safeBotMsg = SanitizeInput(new string(botResponse.Take(300).ToArray()));
|
|
||||||
|
|
||||||
var promptBuilder = new StringBuilder();
|
|
||||||
promptBuilder.AppendLine("Tu tarea es actualizar un resumen de conversación. Basado en el <resumen_anterior> y el <ultimo_intercambio>, crea un nuevo resumen conciso.");
|
|
||||||
promptBuilder.AppendLine($"<resumen_anterior>{safeOldSummary}</resumen_anterior>");
|
|
||||||
promptBuilder.AppendLine("<ultimo_intercambio>");
|
|
||||||
promptBuilder.AppendLine($"Usuario: {safeUserMsg}");
|
|
||||||
promptBuilder.AppendLine($"Bot: {safeBotMsg}...");
|
|
||||||
promptBuilder.AppendLine("</ultimo_intercambio>");
|
|
||||||
promptBuilder.AppendLine("\nResponde SOLO con el nuevo resumen.");
|
|
||||||
|
|
||||||
var requestData = new GeminiRequest
|
|
||||||
{
|
|
||||||
Contents = new[] { new Content { Parts = new[] { new Part { Text = promptBuilder.ToString() } } } },
|
|
||||||
SafetySettings = GetDefaultSafetySettings()
|
|
||||||
};
|
|
||||||
|
|
||||||
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
|
|
||||||
var httpClient = _httpClientFactory.CreateClient();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
|
|
||||||
if (!response.IsSuccessStatusCode) return safeOldSummary;
|
|
||||||
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
|
||||||
var newSummary = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
|
|
||||||
return newSummary ?? safeOldSummary;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Excepción en UpdateConversationSummaryAsync.");
|
|
||||||
return safeOldSummary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IntentType> GetIntentAsync(string userMessage, string? activeArticleContent, string? conversationSummary)
|
|
||||||
{
|
|
||||||
string safeUserMsg = SanitizeInput(userMessage);
|
|
||||||
string safeSummary = SanitizeInput(conversationSummary);
|
|
||||||
string safeArticle = SanitizeInput(new string((activeArticleContent ?? "").Take(1000).ToArray()));
|
|
||||||
|
|
||||||
var promptBuilder = new StringBuilder();
|
|
||||||
promptBuilder.AppendLine("Actúa como un router de intenciones. Analiza la <pregunta_usuario> y decide qué fuente de información usar.");
|
|
||||||
promptBuilder.AppendLine("Categorías posibles: [ARTICULO_ACTUAL], [BASE_DE_CONOCIMIENTO], [NOTICIAS_PORTADA].");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(safeSummary))
|
|
||||||
promptBuilder.AppendLine($"<resumen_conversacion>{safeSummary}</resumen_conversacion>");
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(safeArticle))
|
|
||||||
promptBuilder.AppendLine($"<contexto_articulo>{safeArticle}...</contexto_articulo>");
|
|
||||||
|
|
||||||
promptBuilder.AppendLine("\n--- CRITERIOS DE DECISIÓN ESTRICTOS ---");
|
|
||||||
promptBuilder.AppendLine("1. [ARTICULO_ACTUAL]: SOLO si la pregunta es sobre el MISMO TEMA del <contexto_articulo>.");
|
|
||||||
promptBuilder.AppendLine(" Ejemplos: '¿qué más dice?', 'cuándo pasó?', 'quién es?', 'dame detalles'.");
|
|
||||||
promptBuilder.AppendLine(" IMPORTANTE: Si la pregunta menciona un tema DIFERENTE al artículo, NO uses esta categoría.");
|
|
||||||
promptBuilder.AppendLine("");
|
|
||||||
promptBuilder.AppendLine("2. [NOTICIAS_PORTADA]: Si la pregunta es sobre:");
|
|
||||||
promptBuilder.AppendLine(" - Noticias generales ('¿qué hay?', '¿algo más?', 'novedades')");
|
|
||||||
promptBuilder.AppendLine(" - Un tema DIFERENTE al del artículo actual");
|
|
||||||
promptBuilder.AppendLine(" - Cualquier tema que NO esté en el <contexto_articulo>");
|
|
||||||
promptBuilder.AppendLine("");
|
|
||||||
promptBuilder.AppendLine("3. [BASE_DE_CONOCIMIENTO]: Solo para preguntas sobre el diario 'El Día' como empresa/organización.");
|
|
||||||
promptBuilder.AppendLine($"\n<pregunta_usuario>{safeUserMsg}</pregunta_usuario>");
|
|
||||||
promptBuilder.AppendLine("\nResponde ÚNICAMENTE con el nombre de la categoría entre corchetes. Si hay duda, usa [NOTICIAS_PORTADA].");
|
|
||||||
|
|
||||||
var requestData = new GeminiRequest
|
|
||||||
{
|
|
||||||
Contents = new[] { new Content { Parts = new[] { new Part { Text = promptBuilder.ToString() } } } },
|
|
||||||
SafetySettings = GetDefaultSafetySettings()
|
|
||||||
};
|
|
||||||
|
|
||||||
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
|
|
||||||
var httpClient = _httpClientFactory.CreateClient();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData);
|
|
||||||
if (!response.IsSuccessStatusCode) return IntentType.Homepage;
|
|
||||||
var geminiResponse = await response.Content.ReadFromJsonAsync<GeminiResponse>();
|
|
||||||
var responseText = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim() ?? "";
|
|
||||||
|
|
||||||
if (responseText.Contains("ARTICULO_ACTUAL")) return IntentType.Article;
|
|
||||||
if (responseText.Contains("BASE_DE_CONOCIMIENTO")) return IntentType.KnowledgeBase;
|
|
||||||
return IntentType.Homepage;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Excepción en GetIntentAsync.");
|
|
||||||
return IntentType.Homepage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveConversationLogAsync(string userMessage, string botReply)
|
private async Task SaveConversationLogAsync(string userMessage, string botReply)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// usamos dbContext injectado (Scoped) directamente
|
|
||||||
_dbContext.ConversacionLogs.Add(new ConversacionLog
|
_dbContext.ConversacionLogs.Add(new ConversacionLog
|
||||||
{
|
{
|
||||||
UsuarioMensaje = userMessage,
|
UsuarioMensaje = userMessage,
|
||||||
@@ -517,13 +463,11 @@ namespace ChatbotApi.Services
|
|||||||
var titleTerms = Tokenize(article.Title);
|
var titleTerms = Tokenize(article.Title);
|
||||||
double score = CalculateJaccardSimilarity(userTerms, titleTerms);
|
double score = CalculateJaccardSimilarity(userTerms, titleTerms);
|
||||||
|
|
||||||
// Boost: Palabras clave compartidas (longitud > 3)
|
|
||||||
if (userTerms.Intersect(titleTerms).Any(t => t.Length > 3))
|
if (userTerms.Intersect(titleTerms).Any(t => t.Length > 3))
|
||||||
{
|
{
|
||||||
score += 0.2;
|
score += 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aumentar puntaje si los términos son consecutivos en el título (coincidencia de frase)
|
|
||||||
if (article.Title.IndexOf(userMessage, StringComparison.OrdinalIgnoreCase) >= 0)
|
if (article.Title.IndexOf(userMessage, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
{
|
{
|
||||||
score += 0.5;
|
score += 0.5;
|
||||||
@@ -536,7 +480,6 @@ namespace ChatbotApi.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Umbral mínimo de relevancia: Reducido a 0.05 para capturar coincidencias de una sola palabra en títulos largos
|
|
||||||
return maxScore >= 0.05 ? bestMatch : null;
|
return maxScore >= 0.05 ? bestMatch : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +533,7 @@ namespace ChatbotApi.Services
|
|||||||
return normalizedText
|
return normalizedText
|
||||||
.Split()
|
.Split()
|
||||||
.Select(x => x.Trim(punctuation))
|
.Select(x => x.Trim(punctuation))
|
||||||
.Where(x => x.Length > 2) // ignorar palabras muy cortas
|
.Where(x => x.Length > 2)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Gemini": {
|
"Gemini": {
|
||||||
"GeminiApiUrl": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse&key=",
|
"GeminiApiUrl": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:streamGenerateContent?alt=sse&key=",
|
||||||
"GeminiApiKey": ""
|
"GeminiApiKey": ""
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {},
|
"ConnectionStrings": {},
|
||||||
|
|||||||
Reference in New Issue
Block a user