feat(chat): implement adaptive timeout and enhanced search functionality

- Introduced adaptive timeout logic for web searches and chat streaming, improving responsiveness and efficiency.
- Updated `smartSearchRag` method to support timeout adjustments based on search rounds.
- Enhanced `executeProgressiveSearch` to utilize adaptive timeouts and improve search result handling.
- Added new utility functions for calculating adaptive timeouts based on rounds and attempts.
- Integrated event listeners for chat session management, improving user experience during chat interactions.
- Refactored chat message handling to accommodate new streaming and timeout features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-27 17:32:46 -03:00
1 parent f2e8a1b933
commit 8c546f6474
13 files changed
+1547 -271

No files matched your search

+56
View File
@@ -115,6 +115,7 @@ dependencies = [
"regex",
"reqwest 0.12.24",
"rfd",
"rusqlite",
"scraper",
"serde",
"serde_json",
@@ -1338,6 +1339,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1902,12 +1915,30 @@ dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]]
name = "hashbrown"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "headless_chrome"
version = "1.0.18"
@@ -2578,6 +2609,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
@@ -4160,6 +4202,20 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rusqlite"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
"bitflags 2.10.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
+1
View File
@@ -46,3 +46,4 @@ walkdir = "2.5"
tauri-plugin-autostart = "2"
dirs = "5.0"
rfd = "0.14"
rusqlite = { version = "0.31", features = ["bundled"] }
+308
View File
@@ -0,0 +1,308 @@
use rusqlite::{Connection, Result as SqliteResult, params};
use serde::{Serialize, Deserialize};
use chrono::{DateTime, Utc};
use tauri::{AppHandle, Manager};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatSession {
pub id: String,
pub title: String,
pub emoji: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatMessage {
pub id: Option<i64>,
pub session_id: String,
pub role: String,
pub content: String,
pub metadata: Option<String>,
pub created_at: DateTime<Utc>,
}
pub struct Database {
conn: Connection,
}
impl Database {
/// Cria ou abre conexão com o banco de dados
pub fn new(app_handle: &AppHandle) -> SqliteResult<Self> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
Some(format!("Failed to get app data dir: {}", e))
)
})?;
std::fs::create_dir_all(&app_data_dir)
.map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_IOERR),
Some(format!("Failed to create app data dir: {}", e))
)
})?;
let db_path = app_data_dir.join("ollahub.db");
let conn = Connection::open(&db_path)?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
/// Inicializa o schema do banco de dados
fn init_schema(&self) -> SqliteResult<()> {
// Tabela de sessões
self.conn.execute(
"CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
emoji TEXT DEFAULT '💬',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)",
[],
)?;
// Tabela de mensagens
self.conn.execute(
"CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
metadata TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)",
[],
)?;
// Tabela de documentos RAG
self.conn.execute(
"CREATE TABLE IF NOT EXISTS rag_documents (
id TEXT PRIMARY KEY,
session_id TEXT,
source_url TEXT,
content TEXT NOT NULL,
embedding BLOB,
created_at TEXT NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)",
[],
)?;
// Índices para performance
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_rag_session_id ON rag_documents(session_id)",
[],
)?;
Ok(())
}
/// Cria uma nova sessão de chat
pub fn create_session(&self, session: &ChatSession) -> SqliteResult<()> {
self.conn.execute(
"INSERT INTO sessions (id, title, emoji, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
title = ?2,
emoji = ?3,
updated_at = ?5",
params![
session.id,
session.title,
session.emoji,
session.created_at.to_rfc3339(),
session.updated_at.to_rfc3339()
],
)?;
Ok(())
}
/// Atualiza uma sessão existente
pub fn update_session(&self, session: &ChatSession) -> SqliteResult<()> {
self.conn.execute(
"UPDATE sessions SET title = ?1, emoji = ?2, updated_at = ?3 WHERE id = ?4",
params![
session.title,
session.emoji,
session.updated_at.to_rfc3339(),
session.id
],
)?;
Ok(())
}
/// Busca uma sessão por ID
pub fn get_session(&self, session_id: &str) -> SqliteResult<Option<ChatSession>> {
let mut stmt = self.conn.prepare(
"SELECT id, title, emoji, created_at, updated_at FROM sessions WHERE id = ?1"
)?;
let mut rows = stmt.query_map(params![session_id], |row| {
Ok(ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
})
})?;
if let Some(row) = rows.next() {
row.map(Some)
} else {
Ok(None)
}
}
/// Lista todas as sessões ordenadas por updated_at DESC
pub fn list_sessions(&self) -> SqliteResult<Vec<ChatSession>> {
let mut stmt = self.conn.prepare(
"SELECT id, title, emoji, created_at, updated_at FROM sessions ORDER BY updated_at DESC"
)?;
let rows = stmt.query_map([], |row| {
Ok(ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
})
})?;
let mut sessions = Vec::new();
for row in rows {
sessions.push(row?);
}
Ok(sessions)
}
/// Deleta uma sessão e todas as suas mensagens
pub fn delete_session(&self, session_id: &str) -> SqliteResult<()> {
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])?;
Ok(())
}
/// Adiciona uma mensagem a uma sessão
pub fn add_message(&self, message: &ChatMessage) -> SqliteResult<i64> {
self.conn.execute(
"INSERT INTO messages (session_id, role, content, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
message.session_id,
message.role,
message.content,
message.metadata,
message.created_at.to_rfc3339()
],
)?;
// Atualizar updated_at da sessão
self.conn.execute(
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
params![
message.created_at.to_rfc3339(),
message.session_id
],
)?;
Ok(self.conn.last_insert_rowid())
}
/// Busca todas as mensagens de uma sessão
pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> {
let mut stmt = self.conn.prepare(
"SELECT id, session_id, role, content, metadata, created_at
FROM messages
WHERE session_id = ?1
ORDER BY created_at ASC"
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok(ChatMessage {
id: Some(row.get(0)?),
session_id: row.get(1)?,
role: row.get(2)?,
content: row.get(3)?,
metadata: row.get(4)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(5, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
})
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
Ok(messages)
}
/// Salva um documento RAG
pub fn save_rag_document(
&self,
id: &str,
session_id: Option<&str>,
source_url: Option<&str>,
content: &str,
embedding: Option<&[u8]>,
) -> SqliteResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO rag_documents (id, session_id, source_url, content, embedding, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
id,
session_id,
source_url,
content,
embedding,
Utc::now().to_rfc3339()
],
)?;
Ok(())
}
/// Busca documentos RAG por sessão
pub fn get_rag_documents(&self, session_id: &str) -> SqliteResult<Vec<(String, String, Option<String>)>> {
let mut stmt = self.conn.prepare(
"SELECT id, content, source_url FROM rag_documents WHERE session_id = ?1"
)?;
let rows = stmt.query_map(params![session_id], |row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
))
})?;
let mut docs = Vec::new();
for row in rows {
docs.push(row?);
}
Ok(docs)
}
}
+289
View File
@@ -20,6 +20,7 @@ mod scheduler_loop;
mod sources_config;
mod system_monitor;
mod intent_classifier;
mod db;
use web_scraper::{
ScrapedContent,
@@ -50,6 +51,27 @@ struct Message {
metadata: Option<serde_json::Value>,
}
// Eventos para comunicação Frontend <-> Rust
#[derive(serde::Serialize, Clone)]
struct ChatCreatedEvent {
session_id: String,
title: String,
emoji: String,
}
#[derive(serde::Serialize, Clone)]
struct ChatTokenEvent {
session_id: String,
content: String,
done: bool,
}
#[derive(serde::Serialize, Clone)]
struct ChatErrorEvent {
session_id: String,
error: String,
}
#[derive(serde::Serialize)]
struct DownloadProgress {
status: String, // "pulling", "verifying", "success"
@@ -2599,6 +2621,272 @@ fn classify_intent(query: String) -> String {
}
}
/// Comando principal para streaming de chat via Rust
#[command]
async fn chat_stream(
window: Window,
app_handle: AppHandle,
session_id: Option<String>,
messages: Vec<Message>,
model: String,
system_prompt: Option<String>,
enable_rag: Option<bool>,
) -> Result<String, String> {
use uuid::Uuid;
use ollama_client::OllamaClient;
use futures_util::StreamExt;
use db::{Database, ChatSession, ChatMessage};
// Gerar ou usar session_id existente
let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
let enable_rag = enable_rag.unwrap_or(false);
// Verificar se é nova sessão (apenas 1 mensagem do usuário)
let is_new_session = messages.len() == 1 && messages[0].role == "user";
// Variáveis para título e emoji (usadas depois na persistência)
let (title, emoji) = if is_new_session {
let user_input = &messages[0].content;
let ollama_client = OllamaClient::new(None);
// Tentar gerar título (com timeout curto)
let generated_title = match tokio::time::timeout(
tokio::time::Duration::from_secs(5),
ollama_client.generate_title(&model, user_input)
).await {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
log::warn!("Erro ao gerar título: {}. Usando fallback.", e);
// Fallback: primeiras palavras da pergunta
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
},
Err(_) => {
log::warn!("Timeout ao gerar título. Usando fallback.");
user_input.split_whitespace().take(5).collect::<Vec<_>>().join(" ")
}
};
let generated_emoji = OllamaClient::generate_emoji(&generated_title);
// Emitir evento de chat criado
let created_event = ChatCreatedEvent {
session_id: session_id.clone(),
title: generated_title.clone(),
emoji: generated_emoji.clone(),
};
if let Err(e) = window.emit("chat-created", &created_event) {
log::warn!("Erro ao emitir evento chat-created: {}", e);
}
(generated_title, generated_emoji)
} else {
(String::new(), "💬".to_string())
};
// 2. Preparar mensagens para Ollama
let mut ollama_messages = Vec::new();
// Adicionar system prompt se fornecido
if let Some(sys_prompt) = system_prompt {
ollama_messages.push(serde_json::json!({
"role": "system",
"content": sys_prompt
}));
}
// Converter mensagens para formato Ollama
for msg in &messages {
ollama_messages.push(serde_json::json!({
"role": msg.role,
"content": msg.content
}));
}
// 3. TODO: Classificar intent e aplicar RAG se necessário
// if enable_rag {
// let intent = classify_intent(messages.last().unwrap().content.clone());
// // Buscar contexto via RAG
// // Injetar no system prompt
// }
// 4. Fazer requisição streaming para Ollama
let ollama_client = OllamaClient::new(None);
ollama_client.check_connection().await?;
let request = serde_json::json!({
"model": model,
"messages": ollama_messages,
"stream": true
});
// Usar reqwest diretamente para streaming
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = "http://localhost:11434/api/chat";
let response = client
.post(url)
.json(&request)
.send()
.await
.map_err(|e| format!("Failed to send request to Ollama: {}", e))?;
if !response.status().is_success() {
let error_msg = format!("Ollama returned status: {}", response.status());
let error_event = ChatErrorEvent {
session_id: session_id.clone(),
error: error_msg.clone(),
};
let _ = window.emit("chat-error", &error_event);
return Err(error_msg);
}
// 5. Processar stream e emitir tokens
// IMPORTANTE: O Ollama envia tokens INCREMENTAIS (cada chunk contém apenas o novo conteúdo)
let mut stream = response.bytes_stream();
let mut buffer = String::new();
let mut full_content = String::new();
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
let chunk_str = String::from_utf8_lossy(&chunk);
buffer.push_str(&chunk_str);
// Processar linhas completas (separadas por \n)
while let Some(pos) = buffer.find('\n') {
let line = buffer[..pos].trim().to_string();
buffer = buffer[pos + 1..].to_string();
if line.is_empty() {
continue;
}
// Tentar deserializar como JSON do Ollama
match serde_json::from_str::<serde_json::Value>(&line) {
Ok(json) => {
// Verificar se stream terminou primeiro
let is_done = json.get("done").and_then(|d| d.as_bool()) == Some(true);
// Extrair conteúdo do chunk (Ollama envia tokens incrementais)
if let Some(message) = json.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
// O Ollama envia apenas o NOVO conteúdo em cada chunk, não o acumulado
// Então podemos emitir diretamente
if !content.is_empty() {
full_content.push_str(content);
// Emitir token para frontend
let token_event = ChatTokenEvent {
session_id: session_id.clone(),
content: content.to_string(),
done: false,
};
if let Err(e) = window.emit("chat-token", &token_event) {
log::warn!("Erro ao emitir token: {}", e);
}
}
}
}
// Verificar se stream terminou
if is_done {
// Emitir evento final
let final_event = ChatTokenEvent {
session_id: session_id.clone(),
content: String::new(),
done: true,
};
let _ = window.emit("chat-token", &final_event);
break;
}
}
Err(e) => {
log::debug!("Failed to parse JSON chunk: {} - Line: {}", e, line);
// Continuar mesmo com erro de parse
}
}
}
}
// 6. Persistir sessão e mensagens no SQLite
match Database::new(&app_handle) {
Ok(db) => {
let now = Utc::now();
// Criar ou atualizar sessão
let session = if is_new_session && !title.is_empty() {
ChatSession {
id: session_id.clone(),
title,
emoji,
created_at: now,
updated_at: now,
}
} else {
// Buscar sessão existente ou criar nova
match db.get_session(&session_id) {
Ok(Some(mut existing)) => {
existing.updated_at = now;
existing
}
_ => ChatSession {
id: session_id.clone(),
title: "Nova Conversa".to_string(),
emoji: "💬".to_string(),
created_at: now,
updated_at: now,
}
}
};
if let Err(e) = db.create_session(&session) {
log::warn!("Erro ao salvar sessão: {}", e);
}
// Salvar mensagens do usuário
for msg in &messages {
let chat_msg = ChatMessage {
id: None,
session_id: session_id.clone(),
role: msg.role.clone(),
content: msg.content.clone(),
metadata: msg.metadata.as_ref().and_then(|m| serde_json::to_string(m).ok()),
created_at: now,
};
if let Err(e) = db.add_message(&chat_msg) {
log::warn!("Erro ao salvar mensagem: {}", e);
}
}
// Salvar mensagem final do assistente
if !full_content.is_empty() {
let assistant_msg = ChatMessage {
id: None,
session_id: session_id.clone(),
role: "assistant".to_string(),
content: full_content,
metadata: None,
created_at: Utc::now(),
};
if let Err(e) = db.add_message(&assistant_msg) {
log::warn!("Erro ao salvar mensagem do assistente: {}", e);
}
}
}
Err(e) => {
log::warn!("Erro ao inicializar banco de dados: {}", e);
}
}
Ok(session_id)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -2678,6 +2966,7 @@ pub fn run() {
.manage(Arc::new(Mutex::new(None::<Arc<Browser>>)) as BrowserState)
.manage(Arc::new(Mutex::new(HashMap::<String, Arc<Mutex<()>>>::new())) as FileLockMap)
.invoke_handler(tauri::generate_handler![
chat_stream,
check_ollama_installed,
check_ollama_running,
get_system_specs,
+106 -2
View File
@@ -29,8 +29,8 @@ struct OllamaMessageResponse {
/// Cliente Ollama headless (para execução em background)
pub struct OllamaClient {
base_url: String,
client: reqwest::Client,
pub(crate) base_url: String,
pub(crate) client: reqwest::Client,
}
impl OllamaClient {
@@ -148,5 +148,109 @@ impl OllamaClient {
Ok(full_response.trim().to_string())
}
/// Gera um título curto (3-5 palavras) para a pergunta do usuário
pub async fn generate_title(&self, model: &str, user_input: &str) -> Result<String, String> {
let system_prompt = "Você é um gerador de títulos. Responda APENAS com um título de 3-5 palavras que resuma a pergunta. Nada mais, sem explicações.";
let messages = vec![
OllamaMessage {
role: "system".to_string(),
content: system_prompt.to_string(),
},
OllamaMessage {
role: "user".to_string(),
content: format!("Gere um título para: {}", user_input),
},
];
let request = OllamaChatRequest {
model: model.to_string(),
messages,
stream: true,
};
let url = format!("{}/api/chat", self.base_url);
let response = self.client
.post(&url)
.json(&request)
.timeout(std::time::Duration::from_secs(10)) // Timeout curto para resposta rápida
.send()
.await
.map_err(|e| format!("Failed to send title request: {}", e))?;
if !response.status().is_success() {
return Err(format!("Ollama returned status: {}", response.status()));
}
// Ler stream e acumular resposta (limitado a ~50 caracteres)
let mut full_response = String::new();
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Failed to read chunk: {}", e))?;
let text = String::from_utf8_lossy(&chunk);
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<OllamaChunk>(line) {
Ok(chunk_data) => {
if let Some(message) = chunk_data.message {
full_response.push_str(&message.content);
// Limitar tamanho para evitar respostas longas
if full_response.len() > 50 {
break;
}
}
if chunk_data.done == Some(true) {
break;
}
}
Err(_) => continue,
}
}
// Se já temos resposta suficiente, parar
if full_response.len() > 30 {
break;
}
}
let title = full_response.trim().to_string();
// Fallback se título estiver vazio ou muito longo
if title.is_empty() || title.len() > 50 {
// Extrair primeiras palavras da pergunta como fallback
let words: Vec<&str> = user_input.split_whitespace().take(5).collect();
Ok(words.join(" "))
} else {
Ok(title)
}
}
/// Gera emoji baseado no título
pub fn generate_emoji(title: &str) -> String {
let title_lower = title.to_lowercase();
// Keywords para emojis
if title_lower.contains("código") || title_lower.contains("program") || title_lower.contains("code") {
"💻".to_string()
} else if title_lower.contains("pergunta") || title_lower.contains("question") || title_lower.contains("como") {
"❓".to_string()
} else if title_lower.contains("explica") || title_lower.contains("explain") {
"📚".to_string()
} else if title_lower.contains("ajuda") || title_lower.contains("help") {
"🆘".to_string()
} else {
"💬".to_string() // Default
}
}
}