Fix: Focus de Textbox y Recolocación de Contexto de Nota

This commit is contained in:
2025-11-21 12:51:00 -03:00
parent 01783a52cc
commit c0bd373db1
2 changed files with 106 additions and 33 deletions

View File

@@ -33,6 +33,11 @@ public class GeminiResponse { [JsonPropertyName("candidates")] public Candidate[
public class Candidate { [JsonPropertyName("content")] public Content Content { 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 GeminiStreamingResponse { [JsonPropertyName("candidates")] public StreamingCandidate[] Candidates { get; set; } = default!; }
public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; } public class StreamingCandidate { [JsonPropertyName("content")] public Content Content { get; set; } = default!; }
public class NewsArticleLink
{
public required string Title { get; set; }
public required string Url { get; set; }
}
public enum IntentType { Article, KnowledgeBase, Homepage } public enum IntentType { Article, KnowledgeBase, Homepage }
namespace ChatbotApi.Controllers namespace ChatbotApi.Controllers
@@ -213,8 +218,34 @@ namespace ChatbotApi.Controllers
case IntentType.Homepage: case IntentType.Homepage:
default: default:
_logger.LogInformation("Ejecutando intención: Noticias de Portada."); _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."; // 1. Obtenemos la lista de artículos de la portada.
var articles = await GetWebsiteNewsAsync(_siteUrl, 25);
// 2. Usamos la IA para encontrar el mejor artículo.
var bestMatch = await FindBestMatchingArticleAsync(userMessage, articles);
if (bestMatch != null)
{
// 3. SI ENCONTRAMOS UN ARTÍCULO: Scrapeamos su contenido y preparamos el prompt de síntesis.
_logger.LogInformation("Artículo relevante encontrado: {Title}", bestMatch.Title);
string articleContent = await GetArticleContentAsync(bestMatch.Url) ?? "No se pudo leer el contenido del artículo.";
context = articleContent;
promptInstructions = $"La pregunta del usuario es '{userMessage}'. Basado en el CONTEXTO (el contenido de un artículo), tu tarea es:\n1. Escribir un resumen muy conciso (una o dos frases) que responda directamente a la pregunta del usuario.\n2. Incluir el título completo del artículo y su enlace en formato Markdown: '[{bestMatch.Title}]({bestMatch.Url})'.\n3. Invitar amablemente al usuario a preguntar más sobre este tema.";
}
else
{
// 4. SI NO ENCONTRAMOS NADA: Fallback al comportamiento antiguo de mostrar la lista.
_logger.LogInformation("No se encontró un artículo específico. Mostrando un resumen general de la portada.");
var homepageContextBuilder = new StringBuilder();
homepageContextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
foreach (var article in articles)
{
homepageContextBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}");
}
promptInstructions = "Tu tarea es responder la 'PREGUNTA DEL USUARIO' basándote en la siguiente lista de noticias de portada. Si no encuentras una respuesta directa, informa al usuario sobre los temas principales disponibles.";
}
break; break;
} }
} }
@@ -352,62 +383,84 @@ namespace ChatbotApi.Controllers
} }
} }
private async Task<string> GetWebsiteNewsAsync(string url, int cantidad) private async Task<List<NewsArticleLink>> GetWebsiteNewsAsync(string url, int cantidad)
{ {
var newsList = new List<NewsArticleLink>();
try try
{ {
var web = new HtmlWeb(); var web = new HtmlWeb();
var doc = await web.LoadFromWebAsync(url); var doc = await web.LoadFromWebAsync(url);
var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]"); var articleNodes = doc.DocumentNode.SelectNodes("//article[contains(@class, 'item')]");
if (articleNodes == null || !articleNodes.Any()) if (articleNodes == null) return newsList;
{
_logger.LogWarning("No se encontraron nodos de <article> en la URL {Url}", url);
return string.Empty;
}
var contextBuilder = new StringBuilder();
contextBuilder.AppendLine("Lista de noticias principales extraídas de la página:");
var urlsProcesadas = new HashSet<string>(); var urlsProcesadas = new HashSet<string>();
int count = 0;
foreach (var articleNode in articleNodes) foreach (var articleNode in articleNodes)
{ {
if (newsList.Count >= cantidad) break;
var linkNode = articleNode.SelectSingleNode(".//a[@href]"); var linkNode = articleNode.SelectSingleNode(".//a[@href]");
var titleNode = articleNode.SelectSingleNode(".//h2"); var titleNode = articleNode.SelectSingleNode(".//h2");
if (linkNode != null && titleNode != null) if (linkNode != null && titleNode != null)
{ {
var relativeUrl = linkNode.GetAttributeValue("href", string.Empty); var relativeUrl = linkNode.GetAttributeValue("href", string.Empty);
if (!string.IsNullOrEmpty(relativeUrl) && relativeUrl != "#" && !urlsProcesadas.Contains(relativeUrl))
if (string.IsNullOrEmpty(relativeUrl) || relativeUrl == "#" || urlsProcesadas.Contains(relativeUrl))
{ {
continue; var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
newsList.Add(new NewsArticleLink
{
Title = CleanTitleText(titleNode.InnerText),
Url = fullUrl
});
urlsProcesadas.Add(relativeUrl);
} }
var cleanTitle = CleanTitleText(titleNode.InnerText);
var fullUrl = relativeUrl.StartsWith("/") ? new Uri(new Uri(url), relativeUrl).ToString() : relativeUrl;
contextBuilder.AppendLine($"- Título: \"{cleanTitle}\", URL: {fullUrl}");
urlsProcesadas.Add(relativeUrl);
count++;
}
if (count >= cantidad)
{
break;
} }
} }
var result = contextBuilder.ToString();
_logger.LogInformation("Scraping de la portada exitoso. Se encontraron {Count} noticias.", count);
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url); _logger.LogError(ex, "No se pudo descargar o procesar la URL {Url}", url);
return string.Empty; }
return newsList;
}
private async Task<NewsArticleLink?> FindBestMatchingArticleAsync(string userMessage, List<NewsArticleLink> articles)
{
if (!articles.Any()) return null;
var promptBuilder = new StringBuilder();
promptBuilder.AppendLine("Tu tarea es actuar como un motor de búsqueda. Dada una PREGUNTA DE USUARIO y una LISTA DE ARTÍCULOS, debes encontrar el artículo más relevante. Responde única y exclusivamente con la URL completa del artículo elegido. Si ningún artículo es relevante, responde con 'N/A'.");
promptBuilder.AppendLine("\n--- LISTA DE ARTÍCULOS ---");
foreach (var article in articles)
{
promptBuilder.AppendLine($"- Título: \"{article.Title}\", URL: {article.Url}");
}
promptBuilder.AppendLine("\n--- PREGUNTA DE USUARIO ---");
promptBuilder.AppendLine(userMessage);
promptBuilder.AppendLine("\n--- URL 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 responseUrl = geminiResponse?.Candidates?.FirstOrDefault()?.Content?.Parts?.FirstOrDefault()?.Text?.Trim();
if (string.IsNullOrEmpty(responseUrl) || responseUrl == "N/A") return null;
// Buscamos el artículo completo en nuestra lista original usando la URL que nos dio la IA
return articles.FirstOrDefault(a => a.Url == responseUrl);
}
catch (Exception ex)
{
_logger.LogError(ex, "Excepción en FindBestMatchingArticleAsync.");
return null;
} }
} }

View File

@@ -37,6 +37,7 @@ const Chatbot: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<null | HTMLDivElement>(null); const messagesEndRef = useRef<null | HTMLDivElement>(null);
const inputRef = useRef<null | HTMLInputElement>(null);
const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => { const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => {
try { try {
// 1. Intentamos obtener el contexto del artículo guardado. // 1. Intentamos obtener el contexto del artículo guardado.
@@ -105,6 +106,24 @@ const Chatbot: React.FC = () => {
} }
}, [messages, isOpen]); }, [messages, isOpen]);
// Este useEffect se encarga de gestionar el foco del campo de texto.
useEffect(() => {
// Solo aplicamos la lógica si la ventana del chat está abierta.
if (isOpen) {
// Si el bot NO está cargando, significa que el usuario puede escribir.
// Esto se cumple en dos escenarios:
// 1. Justo cuando se abre la ventana del chat.
// 2. Justo cuando el bot termina de responder (isLoading pasa de true a false).
if (!isLoading) {
// Usamos un pequeño retardo (100ms) para asegurar que el DOM se haya actualizado
// y cualquier animación de CSS haya terminado antes de intentar hacer foco.
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}
}, [isOpen, isLoading]); // Las dependencias: se ejecuta si cambia `isOpen` o `isLoading`.
const toggleChat = () => setIsOpen(!isOpen); const toggleChat = () => setIsOpen(!isOpen);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -292,6 +311,7 @@ const Chatbot: React.FC = () => {
<form className="input-form" onSubmit={handleSendMessage}> <form className="input-form" onSubmit={handleSendMessage}>
<div className="input-container"> <div className="input-container">
<input <input
ref={inputRef}
type="text" type="text"
placeholder={isLoading ? "Esperando respuesta..." : "Escribe tu consulta..."} placeholder={isLoading ? "Esperando respuesta..." : "Escribe tu consulta..."}
value={inputValue} value={inputValue}