use std::collections::HashSet;
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::RwLock;
use super::bg_worker::BackgroundWorker;
use super::bulk_crawler::{BulkDomainCrawler, FollowExternalLinks, RespectRobots};
use super::crawl_graph::{CrawlGraphStore, InMemoryCrawlGraph};
use super::discovery;
use super::embedder::{Embedder, FastembedEmbedder};
use super::proxy_pool::{ProxyEndpoint, ProxyPool};
use super::search_engine::SearchEngine;
use crate::schema::content::StructuredContent;
#[derive(Debug, Clone)]
pub struct ResearchProgress {
pub stage: &'static str,
pub crawled: usize,
pub total: usize,
pub current_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResearchOptions {
pub query: String,
pub seed: Option<String>,
pub seeds: Option<String>,
pub max_pages: u32,
pub delay_ms: u32,
pub follow_external: bool,
pub min_depth: u32,
pub max_depth: u32,
pub auto_depth: bool,
pub topics: Option<String>,
pub proxies: Option<String>,
pub domain_filter: Vec<String>,
pub dynamic: bool,
pub deep: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ResearchError {
#[error("failed to initialize embedder: {0}")]
Embedder(anyhow::Error),
#[error("no seeds discovered for query")]
NoSeeds,
#[error("crawl failed: {0}")]
Crawl(#[from] anyhow::Error),
}
pub async fn execute_research<F>(
indexer: Arc<RwLock<Arc<dyn SearchEngine + Send + Sync>>>,
graph_store: Option<Arc<dyn CrawlGraphStore + Send + Sync>>,
embedder: Option<Arc<dyn Embedder + Send + Sync>>,
options: ResearchOptions,
progress: F,
) -> Result<
(
Vec<StructuredContent>,
Arc<dyn CrawlGraphStore + Send + Sync>,
),
ResearchError,
>
where
F: Fn(ResearchProgress) + Send + Sync + 'static,
{
progress(ResearchProgress {
stage: "discovering",
crawled: 0,
total: 0,
current_url: None,
});
let embedder: Arc<dyn Embedder> = match embedder {
Some(e) => e,
None => match FastembedEmbedder::new() {
Ok(e) => Arc::new(e),
Err(e) => {
tracing::warn!("fastembed model unavailable, using dummy embedder: {}", e);
Arc::new(crate::engine::embedder::DummyEmbedder)
}
},
};
let graph: Arc<dyn CrawlGraphStore + Send + Sync> = graph_store
.clone()
.unwrap_or_else(|| Arc::new(InMemoryCrawlGraph::new()));
let background_worker = Arc::new(BackgroundWorker::new(
graph.clone(),
Some(embedder.clone()),
256,
));
let topics: Vec<String> = options
.topics
.as_deref()
.map(|s| {
s.split(',')
.map(|t| t.trim().to_lowercase())
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
let auto_discover = options.seed.is_none();
let mut all_seeds: Vec<String> = Vec::new();
if let Some(seed) = &options.seed {
match crate::engine::security_gate::sanitize_url(seed.trim()) {
Ok(clean) => all_seeds.push(clean),
Err(e) => eprintln!("[RESEARCH:exec] User-supplied seed '{}' rejected by security gate: {:?}", seed, e),
}
}
if let Some(seeds_str) = &options.seeds {
for s in seeds_str.split(',') {
let trimmed = s.trim();
if !trimmed.is_empty() {
if let Ok(clean) = crate::engine::security_gate::sanitize_url(trimmed) {
all_seeds.push(clean);
} else {
tracing::warn!("User-supplied seed '{}' rejected by security gate (SSRF/invalid)", trimmed);
}
}
}
}
if all_seeds.is_empty() {
let indexer_guard = indexer.read().await;
let discovered =
discovery::discover_seeds(indexer_guard.as_ref(), Some(graph.as_ref()), &options.query)
.await;
drop(indexer_guard);
match discovered {
Ok(seeds) if !seeds.is_empty() => all_seeds = seeds,
Ok(_) => return Err(ResearchError::NoSeeds),
Err(e) => return Err(ResearchError::Crawl(e)),
}
}
let follow_external = if auto_discover {
true
} else {
options.follow_external
};
let proxy_pool = ProxyPool::new();
if let Some(proxies_str) = &options.proxies {
for url in proxies_str.split(',') {
let url = url.trim();
if url.is_empty() {
continue;
}
if let Ok(ep) = ProxyEndpoint::from_url(url) {
let _ = proxy_pool.add(ep);
}
}
}
let crawler = BulkDomainCrawler::new(
proxy_pool,
100,
30,
options.delay_ms.max(100) as u64,
1,
options.max_pages.clamp(1, 500) as usize,
if follow_external {
FollowExternalLinks::Follow
} else {
FollowExternalLinks::Ignore
},
options.min_depth,
options.max_depth,
)
.with_respect_robots(RespectRobots::Yes)
.with_auto_depth(options.auto_depth)
.with_graph_store(graph.clone())
.with_background_worker(background_worker.clone())
.with_topics(topics)
.with_dynamic(options.dynamic)
.with_deep(options.deep);
let max_pages = options.max_pages.clamp(1, 500) as usize;
let total_estimate = max_pages;
let mut contents: Vec<StructuredContent> = Vec::new();
let mut seen_urls: HashSet<String> = HashSet::new();
let mut remaining = max_pages;
for seed_url in &all_seeds {
if remaining == 0 {
break;
}
progress(ResearchProgress {
stage: "crawling",
crawled: contents.len(),
total: total_estimate,
current_url: Some(seed_url.clone()),
});
let crawled = crawler
.crawl_with_limit(seed_url, remaining)
.await
.map_err(ResearchError::Crawl)?;
for c in crawled {
if seen_urls.insert(c.url.clone()) {
contents.push(c);
remaining = remaining.saturating_sub(1);
progress(ResearchProgress {
stage: "crawling",
crawled: contents.len(),
total: total_estimate,
current_url: Some(seed_url.clone()),
});
}
}
}
progress(ResearchProgress {
stage: "persisting",
crawled: contents.len(),
total: total_estimate,
current_url: None,
});
background_worker.close().await;
progress(ResearchProgress {
stage: "complete",
crawled: contents.len(),
total: total_estimate,
current_url: None,
});
let filtered_contents = if options.domain_filter.is_empty() {
contents
} else {
let filter: std::collections::HashSet<String> = options
.domain_filter
.iter()
.map(|d| d.to_lowercase())
.collect();
contents
.into_iter()
.filter(|c| {
url::Url::parse(&c.url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
.map(|h| filter.contains(&h))
.unwrap_or(false)
})
.collect()
};
Ok((filtered_contents, graph))
}