Feat: Detección de Mensajes Genéricos (1 Llamada a la API)

This commit is contained in:
2025-12-09 14:05:53 -03:00
parent b2c6ea5ffc
commit 7a24538b6c
3 changed files with 127 additions and 27 deletions

View File

@@ -48,7 +48,7 @@ namespace ChatbotApi.Services
_apiUrl = $"{baseUrl}{apiKey}"; _apiUrl = $"{baseUrl}{apiKey}";
} }
// Response model for structured JSON from Gemini // Modelo de respuesta para JSON estructurado de Gemini
private class GeminiStructuredResponse private class GeminiStructuredResponse
{ {
public string intent { get; set; } = "NOTICIAS_PORTADA"; public string intent { get; set; } = "NOTICIAS_PORTADA";
@@ -75,7 +75,7 @@ namespace ChatbotApi.Services
try try
{ {
// Load article if URL provided // Cargar artículo si se proporciona URL
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);
@@ -83,7 +83,7 @@ namespace ChatbotApi.Services
if (articleTask != null) articleContext = await articleTask; if (articleTask != null) articleContext = await articleTask;
// Build context based on heuristics // Construir contexto basado en heurísticas
if (!string.IsNullOrEmpty(articleContext)) if (!string.IsNullOrEmpty(articleContext))
{ {
context = articleContext; context = articleContext;
@@ -101,9 +101,18 @@ namespace ChatbotApi.Services
var bestMatch = FindBestMatchingArticleLocal(safeUserMessage, articles); var bestMatch = FindBestMatchingArticleLocal(safeUserMessage, articles);
if (bestMatch == null) if (bestMatch == null)
{
// Optimización: Solo llamar AI matching si el mensaje parece específico
// Evita llamadas innecesarias para saludos y mensajes genéricos
if (RequiresAIMatching(safeUserMessage))
{ {
bestMatch = await FindBestMatchingArticleAIAsync(safeUserMessage, articles, request.ConversationSummary); bestMatch = await FindBestMatchingArticleAIAsync(safeUserMessage, articles, request.ConversationSummary);
} }
else
{
_logger.LogInformation("Mensaje genérico detectado: '{Message}'. Skipping AI matching.", safeUserMessage);
}
}
if (bestMatch != null && await UrlSecurity.IsSafeUrlAsync(bestMatch.Url)) if (bestMatch != null && await UrlSecurity.IsSafeUrlAsync(bestMatch.Url))
{ {
@@ -122,7 +131,7 @@ namespace ChatbotApi.Services
} }
} }
// Add knowledge base if available // Agregar base de conocimiento si está disponible
var knowledgeItems = await GetKnowledgeItemsAsync(); var knowledgeItems = await GetKnowledgeItemsAsync();
if (knowledgeItems.Any()) if (knowledgeItems.Any())
{ {
@@ -146,7 +155,7 @@ namespace ChatbotApi.Services
yield break; yield break;
} }
// ========== UNIFIED API CALL ========== // ========== LLAMADA API UNIFICADA ==========
var httpClient = _httpClientFactory.CreateClient(); var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(45); httpClient.Timeout = TimeSpan.FromSeconds(45);
@@ -158,7 +167,7 @@ namespace ChatbotApi.Services
? request.SystemPromptOverride ? request.SystemPromptOverride
: await systemPromptsTask; : await systemPromptsTask;
// Build unified meta-prompt // Construir meta-prompt unificado
var promptBuilder = new StringBuilder(); var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("<instrucciones_sistema>"); promptBuilder.AppendLine("<instrucciones_sistema>");
@@ -197,7 +206,7 @@ namespace ChatbotApi.Services
promptBuilder.AppendLine("</instrucciones_sistema>"); promptBuilder.AppendLine("</instrucciones_sistema>");
promptBuilder.AppendLine(); promptBuilder.AppendLine();
// Conversation history // Historial de conversación
if (!string.IsNullOrWhiteSpace(request.ConversationSummary)) if (!string.IsNullOrWhiteSpace(request.ConversationSummary))
{ {
promptBuilder.AppendLine("<historial_conversacion>"); promptBuilder.AppendLine("<historial_conversacion>");
@@ -206,13 +215,13 @@ namespace ChatbotApi.Services
promptBuilder.AppendLine(); promptBuilder.AppendLine();
} }
// Context // Contexto
promptBuilder.AppendLine("<contexto>"); promptBuilder.AppendLine("<contexto>");
promptBuilder.AppendLine(context); promptBuilder.AppendLine(context);
promptBuilder.AppendLine("</contexto>"); promptBuilder.AppendLine("</contexto>");
promptBuilder.AppendLine(); promptBuilder.AppendLine();
// User question // Pregunta del usuario
promptBuilder.AppendLine("<pregunta_usuario>"); promptBuilder.AppendLine("<pregunta_usuario>");
promptBuilder.AppendLine(safeUserMessage); promptBuilder.AppendLine(safeUserMessage);
promptBuilder.AppendLine("</pregunta_usuario>"); promptBuilder.AppendLine("</pregunta_usuario>");
@@ -227,7 +236,7 @@ namespace ChatbotApi.Services
SafetySettings = GetDefaultSafetySettings() SafetySettings = GetDefaultSafetySettings()
}; };
// Use non-streaming endpoint // Usar endpoint sin streaming
var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?"); var nonStreamingApiUrl = _apiUrl.Replace(":streamGenerateContent?alt=sse&", ":generateContent?");
var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData, cancellationToken); var response = await httpClient.PostAsJsonAsync(nonStreamingApiUrl, requestData, cancellationToken);
@@ -259,11 +268,11 @@ namespace ChatbotApi.Services
yield break; yield break;
} }
// Parse JSON response (outside try-catch to allow yield) // Parsear respuesta JSON (fuera del try-catch para permitir yield)
GeminiStructuredResponse? apiResponse = null; GeminiStructuredResponse? apiResponse = null;
try try
{ {
// Extract JSON from markdown code blocks if present // Extraer JSON de bloques de código markdown si están presentes
var jsonContent = jsonText!; var jsonContent = jsonText!;
if (jsonText!.Contains("```json")) if (jsonText!.Contains("```json"))
{ {
@@ -291,7 +300,7 @@ namespace ChatbotApi.Services
} }
catch (JsonException ex) catch (JsonException ex)
{ {
_logger.LogError(ex, "Failed to parse Gemini JSON. Raw response: {JsonText}", jsonText); _logger.LogError(ex, "Error al parsear JSON de Gemini. Respuesta raw: {JsonText}", jsonText);
} }
if (apiResponse == null || string.IsNullOrEmpty(apiResponse.reply)) if (apiResponse == null || string.IsNullOrEmpty(apiResponse.reply))
@@ -300,10 +309,10 @@ namespace ChatbotApi.Services
yield break; yield break;
} }
// Send intent metadata // Enviar metadata de intención
yield return $"INTENT::{apiResponse.intent}"; yield return $"INTENT::{apiResponse.intent}";
// Simulate streaming by chunking the reply // Simular streaming dividiendo la respuesta en fragmentos
string fullReply = apiResponse.reply; string fullReply = apiResponse.reply;
var words = fullReply.Split(' ', StringSplitOptions.RemoveEmptyEntries); var words = fullReply.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var chunkBuilder = new StringBuilder(); var chunkBuilder = new StringBuilder();
@@ -312,7 +321,7 @@ namespace ChatbotApi.Services
{ {
chunkBuilder.Append(word + " "); chunkBuilder.Append(word + " ");
// Send chunk every ~20 characters for smooth streaming // Enviar fragmento cada ~20 caracteres para streaming fluido
if (chunkBuilder.Length >= 20) if (chunkBuilder.Length >= 20)
{ {
yield return chunkBuilder.ToString(); yield return chunkBuilder.ToString();
@@ -321,13 +330,13 @@ namespace ChatbotApi.Services
} }
} }
// Send any remaining text // Enviar cualquier texto restante
if (chunkBuilder.Length > 0) if (chunkBuilder.Length > 0)
{ {
yield return chunkBuilder.ToString(); yield return chunkBuilder.ToString();
} }
// Log conversation (fire-and-forget) // Registrar conversación (fire-and-forget)
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
using (var scope = _serviceProvider.CreateScope()) using (var scope = _serviceProvider.CreateScope())
@@ -346,16 +355,16 @@ namespace ChatbotApi.Services
catch(Exception ex) catch(Exception ex)
{ {
var logger = scope.ServiceProvider.GetRequiredService<ILogger<ChatService>>(); var logger = scope.ServiceProvider.GetRequiredService<ILogger<ChatService>>();
logger.LogError(ex, "Error in background logging"); logger.LogError(ex, "Error en registro en segundo plano");
} }
} }
}); });
// Send summary // Enviar resumen
yield return $"SUMMARY::{apiResponse.summary}"; yield return $"SUMMARY::{apiResponse.summary}";
} }
// --- PRIVATE METHODS --- // --- MÉTODOS PRIVADOS ---
private string SanitizeInput(string? input) private string SanitizeInput(string? input)
{ {
@@ -390,8 +399,99 @@ namespace ChatbotApi.Services
}; };
} }
// NOTE: UpdateConversationSummaryAsync and GetIntentAsync have been REMOVED /// <summary>
// Their functionality is now in the unified StreamMessageAsync call /// Determina si un mensaje requiere búsqueda AI de artículos.
/// Usa enfoque híbrido: heurísticas (longitud, estructura) + patrones comunes.
/// Retorna false para mensajes genéricos (saludos, respuestas cortas, confirmaciones)
/// para evitar llamadas innecesarias a la API y reducir latencia.
/// </summary>
private bool RequiresAIMatching(string userMessage)
{
// Normalizar: lowercase, trim, quitar puntuación final
var normalized = userMessage.Trim().ToLowerInvariant()
.TrimEnd('.', '!', '?', ',', ';');
// Contar palabras (excluyendo puntuación)
var wordCount = normalized
.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Length;
// ========== REGLA 1: Mensajes ultra-cortos (1-2 palabras) ==========
// Probablemente sean saludos o respuestas cortas, SALVO que contengan keywords específicas
if (wordCount <= 2)
{
// Excepciones: keywords de temas que SÍ requieren búsqueda de artículos
var specificKeywords = new[] {
"economía", "economia", "inflación", "inflacion", "dólar", "dolar",
"política", "politica", "elecciones", "gobierno",
"clima", "deporte", "fútbol", "futbol", "boca", "river"
};
// Si NO contiene ningún keyword específico, skip AI
if (!specificKeywords.Any(k => normalized.Contains(k)))
{
return false; // Skip AI - probablemente saludo/respuesta corta
}
}
// ========== REGLA 2: Preguntas casuales cortas ==========
// Si tiene signos de pregunta y es corto (≤4 palabras)
if (userMessage.Contains('?') && wordCount <= 4)
{
var casualQuestions = new[] {
"qué tal", "que tal", "cómo va", "como va",
"cómo estás", "como estas", "cómo andás", "como andas",
"todo bien", "qué onda", "que onda"
};
if (casualQuestions.Any(q => normalized.Contains(q)))
{
return false; // Skip AI - pregunta casual
}
}
// ========== REGLA 3: Lista expandida de patrones comunes ==========
// Mensajes cortos (≤3 palabras) que claramente son genéricos
if (wordCount <= 3)
{
var genericPatterns = new[]
{
// Saludos (incluyendo variantes argentinas)
"hola", "buenas", "buen día", "buenos días", "buenas tardes", "buenas noches",
"buen dia", "buenos dias", "hi", "hello", "hey",
// Confirmaciones/Aceptación (argentinismos incluidos)
"ok", "perfecto", "genial", "bárbaro", "barbaro", "dale", "dale dale",
"está bien", "esta bien", "de acuerdo", "si", "sí", "vale", "listo",
"joya", "buenísimo", "buenisimo", "excelente",
// Agradecimientos
"gracias", "muchas gracias", "mil gracias", "thank you", "thanks",
// Despedidas
"chau", "chao", "adiós", "adios", "hasta luego", "nos vemos", "bye",
// Ayuda genérica
"ayuda", "help", "ayúdame", "ayudame",
// Negaciones simples
"no", "nada", "ninguna", "ninguno"
};
if (genericPatterns.Contains(normalized))
{
return false; // Skip AI - patrón genérico detectado
}
}
// ========== Por defecto: usar AI matching ==========
// Cualquier mensaje que no caiga en las reglas anteriores
// (más de 4 palabras, o contiene keywords específicas, o no está en patrones)
return true;
}
// NOTA: UpdateConversationSummaryAsync y GetIntentAsync han sido REMOVIDOS
// Su funcionalidad ahora está en la llamada unificada StreamMessageAsync
private async Task SaveConversationLogAsync(string userMessage, string botReply) private async Task SaveConversationLogAsync(string userMessage, string botReply)
{ {

View File

@@ -1,4 +1,4 @@
// EN: src/components/SourceManager.tsx // ChatBot\chatbot-admin\src\components\SourceManager.tsx
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid'; import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid';
@@ -143,7 +143,7 @@ const SourceManager: React.FC<SourceManagerProps> = ({ onAuthError }) => {
icon={<DeleteIcon />} icon={<DeleteIcon />}
label="Eliminar" label="Eliminar"
onClick={() => handleDeleteClick(params.id as number)} onClick={() => handleDeleteClick(params.id as number)}
color="inherit" // Keeping it generic to avoid type errors color="inherit" // Mantener genérico para evitar errores de tipo
/>, />,
], ],
}, },

View File

@@ -7,7 +7,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import PlayArrowIcon from '@mui/icons-material/PlayArrow'; // Iconos para "Test" import PlayArrowIcon from '@mui/icons-material/PlayArrow'; // Íconos para "Test"
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import apiClient from '../api/apiClient'; import apiClient from '../api/apiClient';