2025-12-02 21:09:43 -03:00
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 ( " \" > " ) ;
2025-12-09 07:55:11 -03:00
html . push_str ( " <div class= \" collection-icon \" ></div> " ) ;
2025-12-02 21:09:43 -03:00
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 ( " \" > " ) ;
2025-12-09 07:55:11 -03:00
html . push_str ( " <div class= \" collection-icon \" ></div> " ) ;
2025-12-02 21:09:43 -03:00
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 \" > " ) ;
2025-12-09 07:55:11 -03:00
html . push_str ( " <div class= \" no-results-icon \" ></div> " ) ;
2025-12-02 21:09:43 -03:00
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! [
(
2025-12-09 07:55:11 -03:00
" " ,
2025-12-02 21:09:43 -03:00
" Getting Started " ,
" Learn the basics and set up your first bot " ,
) ,
2025-12-09 07:55:11 -03:00
( " " , " Configuration " , " Customize settings and preferences " ) ,
2025-12-02 21:09:43 -03:00
(
" 🔌 " ,
" Integrations " ,
" Connect with external services and APIs " ,
) ,
2025-12-09 07:55:11 -03:00
( " " , " Deployment " , " Deploy your bot to production " ) ,
( " " , " Security " , " Best practices for securing your bot " ) ,
( " " , " Analytics " , " Monitor and analyze bot performance " ) ,
2025-12-02 21:09:43 -03:00
] ;
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 ( '&' , " & " )
. replace ( '<' , " < " )
. replace ( '>' , " > " )
. replace ( '"' , " " " )
. replace ( '\'' , " ' " )
}