use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use chrono::Utc;
use clap::{Parser, Subcommand};
use episteme::adapters::{
BamlDocumentClassifier, BamlResearchAnalyzer, DocumentCliExtractor, DuckDbIngestionStore,
SystemDocumentTools, VaultFileStore, ZkIndexer, prepare_vault_directory,
};
use episteme::classification::ClassificationPipeline;
use episteme::config::Settings;
use episteme::doctor::Doctor;
use episteme::domain::AnalysisProvenance;
use episteme::ingest::Ingestor;
use episteme::stage::stage_source;
use episteme::watch::{StableFileTracker, WatchDecision};
#[derive(Debug, Parser)]
#[command(version, about)]
struct Cli {
#[arg(long, default_value = "episteme.toml")]
config: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Doctor,
Init,
Ingest {
source: PathBuf,
},
Classify {
source: PathBuf,
},
Watch,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let settings = Settings::load(&cli.config)
.with_context(|| format!("failed to load {}", cli.config.display()))?;
match cli.command {
Command::Doctor => run_doctor(&settings),
Command::Init => initialize_directories(&settings),
Command::Ingest { source } => {
let run = ingest_path(&settings, &source).await?;
println!("{}: {}", run.digest, run.stage.as_str());
Ok(())
}
Command::Classify { source } => {
let record = classify_path(&settings, &source).await?;
println!("{}", serde_json::to_string_pretty(&record)?);
Ok(())
}
Command::Watch => watch(&settings).await,
}
}
async fn classify_path(
settings: &Settings,
source_path: &Path,
) -> Result<episteme::domain::ClassificationRecord> {
let inbox = settings.vault_root.join(&settings.inbox_directory);
let state_root = settings
.database_path
.parent()
.context("database path must have a parent directory")?;
let source = stage_source(
&inbox,
source_path,
&state_root.join("staging"),
settings.maximum_source_bytes,
)?;
let timeout = Duration::from_secs(settings.process_timeout_seconds);
let tools = SystemDocumentTools::new(
settings.tools.clone(),
timeout,
settings.maximum_output_bytes,
);
let extractor = DocumentCliExtractor::new(tools, settings.minimum_text_characters);
let classifier = BamlDocumentClassifier::new(settings.classifier.clone(), timeout);
let store = DuckDbIngestionStore::open(&settings.database_path)?;
ClassificationPipeline::new(extractor, classifier, store)
.classify(
&source,
AnalysisProvenance {
function: "ClassifyDocument".to_owned(),
client: "LocalClassifier".to_owned(),
model: settings.classifier.model().to_owned(),
pipeline_version: env!("CARGO_PKG_VERSION").to_owned(),
processed_at: Utc::now().to_rfc3339(),
},
)
.await
.map_err(Into::into)
}
fn run_doctor(settings: &Settings) -> Result<()> {
let report = Doctor::check_tools(&settings.required_tools());
for check in &report.checks {
let status = if check.available { "ok" } else { "missing" };
println!("{status}: {} ({})", check.name, check.path.display());
}
if !report.is_healthy() {
bail!("one or more required tools are unavailable");
}
Ok(())
}
fn initialize_directories(settings: &Settings) -> Result<()> {
prepare_vault_directory(&settings.vault_root, &settings.inbox_directory)?;
VaultFileStore::new(
settings.vault_root.clone(),
settings.research_directory.clone(),
settings.archive_directory.clone(),
)
.prepare_directories()?;
if let Some(parent) = settings.database_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let _store = DuckDbIngestionStore::open(&settings.database_path)?;
Ok(())
}
async fn ingest_path(
settings: &Settings,
source_path: &Path,
) -> Result<episteme::domain::IngestionRun> {
let inbox = settings.vault_root.join(&settings.inbox_directory);
let state_root = settings
.database_path
.parent()
.context("database path must have a parent directory")?;
let source = stage_source(
&inbox,
source_path,
&state_root.join("staging"),
settings.maximum_source_bytes,
)?;
let timeout = Duration::from_secs(settings.process_timeout_seconds);
let tools = SystemDocumentTools::new(
settings.tools.clone(),
timeout,
settings.maximum_output_bytes,
);
let extractor = DocumentCliExtractor::new(tools, settings.minimum_text_characters);
let analyzer = BamlResearchAnalyzer::new(
settings.classifier.clone(),
settings.distiller.clone(),
timeout,
);
let vault = VaultFileStore::new(
settings.vault_root.clone(),
settings.research_directory.clone(),
settings.archive_directory.clone(),
);
let store = DuckDbIngestionStore::open(&settings.database_path)?;
let indexer = ZkIndexer::new(
settings.tools.zk.clone(),
settings.vault_root.clone(),
timeout,
settings.maximum_output_bytes,
);
let ingestor = Ingestor::new(extractor, analyzer, vault, store, indexer);
ingestor
.ingest(
&source,
AnalysisProvenance {
function: "ResearchDistillationPipeline".to_owned(),
client: "LocalClassifier+LocalDistiller".to_owned(),
model: format!(
"classifier={};distiller={}",
settings.classifier.model(),
settings.distiller.model()
),
pipeline_version: env!("CARGO_PKG_VERSION").to_owned(),
processed_at: Utc::now().to_rfc3339(),
},
)
.await
.map_err(Into::into)
}
async fn watch(settings: &Settings) -> Result<()> {
let inbox = settings.vault_root.join(&settings.inbox_directory);
let mut tracker = StableFileTracker::new(&inbox)?;
let mut interval = tokio::time::interval(Duration::from_secs(settings.watch_interval_seconds));
loop {
tokio::select! {
_ = interval.tick() => {
for entry in fs::read_dir(&inbox)
.with_context(|| format!("failed to read {}", inbox.display()))?
{
let path = entry?.path();
if let WatchDecision::Ready(path) = tracker.observe(&path)
&& let Err(error) = ingest_path(settings, &path).await
{
eprintln!("ingestion failed for {}: {error:#}", path.display());
}
}
}
signal = tokio::signal::ctrl_c() => {
signal.context("failed to listen for shutdown signal")?;
return Ok(());
}
}
}
}