botserver/src/research/mod.rs
Rodrigo Rodriguez (Pragmatismo) 224ddd4a03 chore: Remove emoji icons from log messages and UI
Replace emoji icons with plain text/ASCII equivalents to prevent
encoding issues and improve console compatibility:
- Replace checkmarks with *
- Replace x marks with x
- Replace status indicators with text [OK], [ERR], etc.
- Remove decorative emojis from info/debug log messages
- Keep functional emojis in user-facing chat/sentiment features
2025-12-09 07:55:11 -03:00

506 lines
16 KiB
Rust

use crate::shared::state::AppState;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse},
routing::{get, post},
Json, Router,
};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
pub collection: Option<String>,
pub filters: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchRequest {
pub query: Option<String>,
pub collection: Option<String>,
pub filters: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewCollectionRequest {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct KbDocumentRow {
#[diesel(sql_type = diesel::sql_types::Text)]
pub id: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub title: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub content: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub collection_id: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub source_path: String,
}
#[derive(Debug, QueryableByName)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CollectionRow {
#[diesel(sql_type = diesel::sql_types::Text)]
pub id: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub name: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub description: String,
}
pub fn configure_research_routes() -> Router<Arc<AppState>> {
Router::new()
// Collections - match frontend hx-* endpoints
.route("/api/research/collections", get(handle_list_collections))
.route(
"/api/research/collections/new",
post(handle_create_collection),
)
.route("/api/research/collections/{id}", get(handle_get_collection))
// Search
.route("/api/research/search", post(handle_search))
.route("/api/research/recent", get(handle_recent_searches))
.route("/api/research/trending", get(handle_trending_tags))
.route("/api/research/prompts", get(handle_prompts))
// Export
.route(
"/api/research/export-citations",
get(handle_export_citations),
)
}
/// GET /api/research/collections - List all collections
pub async fn handle_list_collections(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let collections = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return get_default_collections();
}
};
let result: Result<Vec<CollectionRow>, _> =
diesel::sql_query("SELECT id, name, description FROM kb_collections ORDER BY name ASC")
.load(&mut db_conn);
match result {
Ok(colls) if !colls.is_empty() => colls
.into_iter()
.map(|c| (c.id, c.name, c.description))
.collect(),
_ => get_default_collections(),
}
})
.await
.unwrap_or_else(|_| get_default_collections());
let mut html = String::new();
for (id, name, description) in &collections {
html.push_str("<div class=\"collection-item\" data-id=\"");
html.push_str(&html_escape(id));
html.push_str("\">");
html.push_str("<div class=\"collection-icon\"></div>");
html.push_str("<div class=\"collection-info\">");
html.push_str("<span class=\"collection-name\">");
html.push_str(&html_escape(name));
html.push_str("</span>");
html.push_str("<span class=\"collection-desc\">");
html.push_str(&html_escape(description));
html.push_str("</span>");
html.push_str("</div>");
html.push_str("<button class=\"btn-icon-sm\" hx-get=\"/api/research/collections/");
html.push_str(&html_escape(id));
html.push_str("\" hx-target=\"#main-results\">");
html.push_str("<svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"9 18 15 12 9 6\"></polyline></svg>");
html.push_str("</button>");
html.push_str("</div>");
}
if collections.is_empty() {
html.push_str("<div class=\"empty-state\">");
html.push_str("<p>No collections yet</p>");
html.push_str("</div>");
}
Html(html)
}
fn get_default_collections() -> Vec<(String, String, String)> {
vec![
(
"general".to_string(),
"General Knowledge".to_string(),
"Default knowledge base".to_string(),
),
(
"docs".to_string(),
"Documentation".to_string(),
"Product documentation".to_string(),
),
(
"faq".to_string(),
"FAQ".to_string(),
"Frequently asked questions".to_string(),
),
]
}
/// POST /api/research/collections/new - Create a new collection
pub async fn handle_create_collection(
State(state): State<Arc<AppState>>,
Json(payload): Json<NewCollectionRequest>,
) -> impl IntoResponse {
let conn = state.conn.clone();
let id = uuid::Uuid::new_v4().to_string();
let name = payload.name.clone();
let description = payload.description.unwrap_or_default();
let id_clone = id.clone();
let name_clone = name.clone();
let desc_clone = description.clone();
let _ = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return;
}
};
let _ = diesel::sql_query(
"INSERT INTO kb_collections (id, name, description) VALUES ($1, $2, $3)",
)
.bind::<diesel::sql_types::Text, _>(&id)
.bind::<diesel::sql_types::Text, _>(&name)
.bind::<diesel::sql_types::Text, _>(&description)
.execute(&mut db_conn);
})
.await;
let mut html = String::new();
html.push_str("<div class=\"collection-item new-item\" data-id=\"");
html.push_str(&html_escape(&id_clone));
html.push_str("\">");
html.push_str("<div class=\"collection-icon\"></div>");
html.push_str("<div class=\"collection-info\">");
html.push_str("<span class=\"collection-name\">");
html.push_str(&html_escape(&name_clone));
html.push_str("</span>");
html.push_str("<span class=\"collection-desc\">");
html.push_str(&html_escape(&desc_clone));
html.push_str("</span>");
html.push_str("</div>");
html.push_str("</div>");
Html(html)
}
/// GET /api/research/collections/{id} - Get collection contents
pub async fn handle_get_collection(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let conn = state.conn.clone();
let documents = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return Vec::new();
}
};
diesel::sql_query(
"SELECT id, title, content, collection_id, source_path FROM kb_documents WHERE collection_id = $1 ORDER BY title ASC LIMIT 50",
)
.bind::<diesel::sql_types::Text, _>(&id)
.load::<KbDocumentRow>(&mut db_conn)
.unwrap_or_default()
})
.await
.unwrap_or_default();
let mut html = String::new();
html.push_str("<div class=\"collection-results\">");
html.push_str("<div class=\"results-header\">");
html.push_str("<h3>Collection Contents</h3>");
html.push_str("<span class=\"result-count\">");
html.push_str(&documents.len().to_string());
html.push_str(" documents</span>");
html.push_str("</div>");
html.push_str("<div class=\"results-list\">");
if documents.is_empty() {
html.push_str("<div class=\"empty-state\">");
html.push_str("<p>No documents in this collection</p>");
html.push_str("<p class=\"hint\">Add documents to build your knowledge base</p>");
html.push_str("</div>");
} else {
for doc in &documents {
html.push_str(&format_search_result(
&doc.id,
&doc.title,
&doc.content,
&doc.source_path,
));
}
}
html.push_str("</div>");
html.push_str("</div>");
Html(html)
}
/// POST /api/research/search - Semantic search
pub async fn handle_search(
State(state): State<Arc<AppState>>,
Json(payload): Json<SearchRequest>,
) -> impl IntoResponse {
let query = payload.query.unwrap_or_default();
if query.trim().is_empty() {
return Html("<div class=\"search-prompt\"><p>Enter a search query to find relevant documents</p></div>".to_string());
}
let conn = state.conn.clone();
let collection = payload.collection;
let results = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(e) => {
log::error!("DB connection error: {}", e);
return Vec::new();
}
};
let search_pattern = format!("%{}%", query.to_lowercase());
let docs = if let Some(coll) = collection {
diesel::sql_query(
"SELECT id, title, content, collection_id, source_path FROM kb_documents WHERE (LOWER(title) LIKE $1 OR LOWER(content) LIKE $1) AND collection_id = $2 ORDER BY title ASC LIMIT 20",
)
.bind::<diesel::sql_types::Text, _>(&search_pattern)
.bind::<diesel::sql_types::Text, _>(&coll)
.load::<KbDocumentRow>(&mut db_conn)
.unwrap_or_default()
} else {
diesel::sql_query(
"SELECT id, title, content, collection_id, source_path FROM kb_documents WHERE LOWER(title) LIKE $1 OR LOWER(content) LIKE $1 ORDER BY title ASC LIMIT 20",
)
.bind::<diesel::sql_types::Text, _>(&search_pattern)
.load::<KbDocumentRow>(&mut db_conn)
.unwrap_or_default()
};
docs
})
.await
.unwrap_or_default();
let mut html = String::new();
html.push_str("<div class=\"search-results\">");
html.push_str("<div class=\"results-header\">");
html.push_str("<h3>Search Results</h3>");
html.push_str("<span class=\"result-count\">");
html.push_str(&results.len().to_string());
html.push_str(" results found</span>");
html.push_str("</div>");
html.push_str("<div class=\"results-list\">");
if results.is_empty() {
html.push_str("<div class=\"no-results\">");
html.push_str("<div class=\"no-results-icon\"></div>");
html.push_str("<h4>No results found</h4>");
html.push_str("<p>Try different keywords or check your spelling</p>");
html.push_str("</div>");
} else {
for doc in &results {
html.push_str(&format_search_result(
&doc.id,
&doc.title,
&doc.content,
&doc.source_path,
));
}
}
html.push_str("</div>");
html.push_str("</div>");
Html(html)
}
fn format_search_result(id: &str, title: &str, content: &str, source: &str) -> String {
let snippet = if content.len() > 200 {
format!("{}...", &content[..200])
} else {
content.to_string()
};
let mut html = String::new();
html.push_str("<div class=\"result-item\" data-id=\"");
html.push_str(&html_escape(id));
html.push_str("\">");
html.push_str("<div class=\"result-header\">");
html.push_str("<h4 class=\"result-title\">");
html.push_str(&html_escape(title));
html.push_str("</h4>");
html.push_str("<span class=\"result-source\">");
html.push_str(&html_escape(source));
html.push_str("</span>");
html.push_str("</div>");
html.push_str("<p class=\"result-snippet\">");
html.push_str(&html_escape(&snippet));
html.push_str("</p>");
html.push_str("<div class=\"result-actions\">");
html.push_str("<button class=\"btn-sm btn-view\">View</button>");
html.push_str("<button class=\"btn-sm btn-cite\">Cite</button>");
html.push_str("<button class=\"btn-sm btn-save\">Save</button>");
html.push_str("</div>");
html.push_str("</div>");
html
}
/// GET /api/research/recent - Recent searches
pub async fn handle_recent_searches(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let recent_searches = vec![
"How to get started",
"API documentation",
"Configuration guide",
"Best practices",
"Troubleshooting",
];
let mut html = String::new();
for search in &recent_searches {
html.push_str(
"<div class=\"recent-item\" hx-post=\"/api/research/search\" hx-vals='{\"query\":\"",
);
html.push_str(&html_escape(search));
html.push_str("\"}' hx-target=\"#main-results\">");
html.push_str("<span class=\"recent-icon\">🕐</span>");
html.push_str("<span class=\"recent-text\">");
html.push_str(&html_escape(search));
html.push_str("</span>");
html.push_str("</div>");
}
if recent_searches.is_empty() {
html.push_str("<div class=\"empty-state small\">");
html.push_str("<p>No recent searches</p>");
html.push_str("</div>");
}
Html(html)
}
/// GET /api/research/trending - Trending tags
pub async fn handle_trending_tags(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let tags = vec![
("getting-started", 42),
("api", 38),
("integration", 25),
("configuration", 22),
("deployment", 18),
("security", 15),
("performance", 12),
("troubleshooting", 10),
];
let mut html = String::new();
html.push_str("<div class=\"trending-tags-list\">");
for (tag, count) in &tags {
html.push_str(
"<span class=\"tag\" hx-post=\"/api/research/search\" hx-vals='{\"query\":\"",
);
html.push_str(&html_escape(tag));
html.push_str("\"}' hx-target=\"#main-results\">");
html.push_str(&html_escape(tag));
html.push_str(" <small>(");
html.push_str(&count.to_string());
html.push_str(")</small>");
html.push_str("</span>");
}
html.push_str("</div>");
Html(html)
}
/// GET /api/research/prompts - Get research prompts/suggestions
pub async fn handle_prompts(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
let prompts = vec![
(
"",
"Getting Started",
"Learn the basics and set up your first bot",
),
("", "Configuration", "Customize settings and preferences"),
(
"🔌",
"Integrations",
"Connect with external services and APIs",
),
("", "Deployment", "Deploy your bot to production"),
("", "Security", "Best practices for securing your bot"),
("", "Analytics", "Monitor and analyze bot performance"),
];
let mut html = String::new();
html.push_str("<div class=\"prompts-grid\">");
for (icon, title, description) in &prompts {
html.push_str(
"<div class=\"prompt-card\" hx-post=\"/api/research/search\" hx-vals='{\"query\":\"",
);
html.push_str(&html_escape(title));
html.push_str("\"}' hx-target=\"#main-results\">");
html.push_str("<div class=\"prompt-icon\">");
html.push_str(icon);
html.push_str("</div>");
html.push_str("<div class=\"prompt-content\">");
html.push_str("<h4>");
html.push_str(&html_escape(title));
html.push_str("</h4>");
html.push_str("<p>");
html.push_str(&html_escape(description));
html.push_str("</p>");
html.push_str("</div>");
html.push_str("</div>");
}
html.push_str("</div>");
Html(html)
}
/// GET /api/research/export-citations - Export citations
pub async fn handle_export_citations(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("<script>alert('Citations exported. Download will begin shortly.');</script>".to_string())
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}