use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use crate::capabilities::file_system::{FileSystem, MemoryFileSystem};
use crate::conversation::ConversationStore;
use crate::core::agent::Agent;
use crate::core::enhancer::RagEnhancer;
use crate::core::registry::ToolRegistry;
use crate::core::system_prompt::build_sql_system_prompt;
use crate::embedding::{
cache::CachedEmbedding, local::LocalEmbedding, openai::OpenAiEmbedding, EmbeddingService,
};
use crate::llm::{
anthropic::AnthropicLlm,
mock::MockLlm,
ollama::OllamaLlm,
openai::OpenAiLlm,
retry::{RetryLlm, RetryPolicy},
LlmService,
};
use crate::secret::SecretStore;
use crate::settings::Settings;
use crate::sqlrunner::sqlite::SqliteRunner;
use crate::sqlrunner::SqlRunner;
use crate::tools::memory::{RememberTool, SaveQueryTool};
use crate::tools::run_sql::RunSqlTool;
use crate::tools::visualize_data::VisualizeDataTool;
use crate::opendbpylot::{OpenDbPylot, OpenDbPylotConfig};
use crate::vectorstore::{file::FileVectorStore, VectorStore};
fn db_identity(settings: &Settings) -> String {
match settings.db_kind.as_str() {
"postgres" | "postgresql" | "mysql" | "mariadb" => {
format!("{}:{}", settings.db_kind, settings.db_connection_string)
}
"duckdb" => format!("duckdb:{}", settings.db_path),
_ => format!("sqlite:{}", settings.db_path),
}
}
fn kb_filename(embedder: &str, settings: &Settings) -> String {
let mut h = DefaultHasher::new();
db_identity(settings).hash(&mut h);
format!("kb_{}_{:016x}.json", embedder, h.finish())
}
fn dialect_for(db_kind: &str) -> &'static str {
match db_kind {
"postgres" | "postgresql" => "PostgreSQL",
"mysql" | "mariadb" => "MySQL",
"duckdb" => "DuckDB",
_ => "SQLite",
}
}
pub fn build_runner(settings: &Settings) -> Result<Arc<dyn SqlRunner>> {
match settings.db_kind.as_str() {
#[cfg(feature = "remote-db")]
"postgres" | "postgresql" => {
use crate::sqlrunner::postgres::PostgresRunner;
if settings.db_connection_string.is_empty() {
return Err(anyhow::anyhow!("db_connection_string is required for PostgreSQL"));
}
Ok(Arc::new(PostgresRunner::new(settings.db_connection_string.clone())))
}
#[cfg(feature = "remote-db")]
"mysql" | "mariadb" => {
use crate::sqlrunner::mysql::MySqlRunner;
if settings.db_connection_string.is_empty() {
return Err(anyhow::anyhow!("db_connection_string is required for MySQL"));
}
Ok(Arc::new(MySqlRunner::new(settings.db_connection_string.clone())))
}
#[cfg(feature = "duckdb")]
"duckdb" => {
use crate::sqlrunner::duckdb::DuckDbRunner;
Ok(Arc::new(DuckDbRunner::new(settings.db_path.clone())?))
}
#[cfg(not(feature = "duckdb"))]
"duckdb" => Err(anyhow::anyhow!(
"db_kind is 'duckdb' but this build lacks the feature — \
reinstall with: cargo install opendbpylot --features duckdb"
)),
_ => Ok(Arc::new(SqliteRunner::new(settings.db_path.clone()))),
}
}
pub fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_env("OPENDBPYLOT_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
let _ = fmt().with_env_filter(filter).with_target(false).without_time().try_init();
}
pub fn init_tracing_stderr() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_env("OPENDBPYLOT_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.without_time()
.try_init();
}
pub fn home() -> PathBuf {
let dir = std::env::var("OPENDBPYLOT_HOME").map(PathBuf::from).unwrap_or_else(|_| {
let base = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(base).join(".opendbpylot")
});
let _ = std::fs::create_dir_all(&dir);
dir
}
struct Components {
llm: Arc<dyn LlmService>,
store: Arc<dyn VectorStore>,
runner: Arc<dyn SqlRunner>,
dialect: &'static str,
}
pub fn resolve_api_key(provider: &str, secrets: &dyn SecretStore) -> Result<Option<String>> {
if let Some(key) = secrets.get(provider)? {
return Ok(Some(key));
}
let var = match provider {
"openai" => "OPENAI_API_KEY",
"anthropic" => "ANTHROPIC_API_KEY",
_ => return Ok(None),
};
Ok(std::env::var(var)
.ok()
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty()))
}
fn build_components(settings: &Settings, secrets: &dyn SecretStore) -> Result<Option<Components>> {
let mut settings = settings.clone();
if settings.db_connection_string.is_empty() {
if let Some(c) = secrets.get("db_connection_string")? {
settings.db_connection_string = c;
}
}
let settings = &settings;
let model = settings.effective_model();
let openai_key = resolve_api_key("openai", secrets)?;
let openai_vault_key = secrets.get("openai")?;
let timeout = (settings.llm_timeout_secs > 0)
.then(|| std::time::Duration::from_secs(settings.llm_timeout_secs));
let llm: Arc<dyn LlmService> = match settings.provider.as_str() {
"openai" => match openai_key.clone() {
Some(k) => {
let mut p = OpenAiLlm::new(k, model);
if let Some(t) = timeout {
p = p.with_timeout(t);
}
Arc::new(p)
}
None => return Ok(None),
},
"anthropic" => match resolve_api_key("anthropic", secrets)? {
Some(k) => {
let mut p = AnthropicLlm::new(k, model);
if let Some(t) = timeout {
p = p.with_timeout(t);
}
Arc::new(p)
}
None => return Ok(None),
},
"ollama" => {
let mut p = OllamaLlm::new(model);
if let Some(t) = timeout {
p = p.with_timeout(t);
}
Arc::new(p)
}
"mock" => Arc::new(MockLlm::with_default_sql()),
_ => return Ok(None),
};
let llm: Arc<dyn LlmService> = if settings.provider == "mock" {
llm
} else {
Arc::new(RetryLlm::new(llm).with_policy(RetryPolicy {
max_retries: settings.llm_max_retries,
..RetryPolicy::default()
}))
};
let cache_path = home().join("cache").join("embeddings.jsonl");
let openai_embedder = |key: String| -> Arc<dyn EmbeddingService> {
Arc::new(CachedEmbedding::new(
Arc::new(OpenAiEmbedding::new(key, "text-embedding-3-small")),
"openai:text-embedding-3-small",
Some(cache_path.clone()),
))
};
let (embedding, embedder_tag): (Arc<dyn EmbeddingService>, &str) =
match settings.embedding_provider.as_str() {
"local" => (Arc::new(LocalEmbedding::new()), "local"),
"openai" => match openai_vault_key.clone() {
Some(k) => (openai_embedder(k), "openai"),
None => anyhow::bail!(
"embedding_provider is 'openai' but no OpenAI API key is stored"
),
},
"fastembed" => {
#[cfg(feature = "fastembed")]
{
(
Arc::new(CachedEmbedding::new(
Arc::new(crate::embedding::fastembed::FastEmbedding::new()?),
"fastembed:all-minilm-l6-v2",
Some(cache_path.clone()),
)),
"fastembed",
)
}
#[cfg(not(feature = "fastembed"))]
anyhow::bail!(
"embedding_provider is 'fastembed' but this build lacks the feature — \
reinstall with: cargo install opendbpylot --features fastembed"
)
}
_ => match openai_vault_key.clone() {
Some(k) if settings.provider != "mock" => (openai_embedder(k), "openai"),
_ => (Arc::new(LocalEmbedding::new()), "local"),
},
};
let kb_file = kb_filename(embedder_tag, settings);
let store: Arc<dyn VectorStore> = Arc::new(FileVectorStore::new(home().join(kb_file), embedding)?);
let runner: Arc<dyn SqlRunner> = build_runner(settings)?;
Ok(Some(Components { llm, store, runner, dialect: dialect_for(&settings.db_kind) }))
}
pub fn build_opendbpylot(
settings: &Settings,
secrets: &dyn SecretStore,
conversations: Arc<dyn ConversationStore>,
) -> Result<Option<OpenDbPylot>> {
let Some(c) = build_components(settings, secrets)? else { return Ok(None) };
Ok(Some(
OpenDbPylot::new(c.llm, c.store)
.with_runner(c.runner)
.with_conversations(conversations)
.with_config(OpenDbPylotConfig {
dialect: c.dialect.to_string(),
allow_llm_to_see_data: true,
review_queue_path: Some(crate::review::ReviewQueue::path_in(&home())),
..Default::default()
}),
))
}
pub struct CoreBuild {
pub opendbpylot: Arc<OpenDbPylot>,
pub agent: Arc<Agent>,
}
pub fn build_core(
settings: &Settings,
secrets: &dyn SecretStore,
conversations: Arc<dyn ConversationStore>,
) -> Result<Option<CoreBuild>> {
let Some(c) = build_components(settings, secrets)? else { return Ok(None) };
let opendbpylot = Arc::new(
OpenDbPylot::new(c.llm.clone(), c.store.clone())
.with_runner(c.runner.clone())
.with_conversations(conversations)
.with_config(OpenDbPylotConfig {
dialect: c.dialect.to_string(),
allow_llm_to_see_data: true,
review_queue_path: Some(crate::review::ReviewQueue::path_in(&home())),
..Default::default()
}),
);
let fs: Arc<dyn FileSystem> = Arc::new(MemoryFileSystem::new());
let mut registry = ToolRegistry::new();
registry.register(Arc::new(RunSqlTool::new(c.runner.clone(), fs.clone())));
registry.register(Arc::new(VisualizeDataTool::new(fs)));
registry.register(Arc::new(RememberTool::new(c.store.clone())));
registry.register(Arc::new(SaveQueryTool::new(c.store.clone())));
let enhancer = Arc::new(RagEnhancer::new(c.store.clone()));
let agent = Arc::new(
Agent::new(c.llm.clone(), Arc::new(registry))
.with_system_prompt(build_sql_system_prompt(c.dialect))
.with_enhancer(enhancer),
);
Ok(Some(CoreBuild { opendbpylot, agent }))
}