use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use skadoosh::llm::{LlmBackend, LlmClient};
use skadoosh::rag::{Embedder, OnnxEmbedder, RagStore, DEFAULT_MAX_SEQ_LEN, DEFAULT_RAG_MODEL};
use skadoosh::Result;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const CLAUSE_CAP: usize = 16;
const DEFAULT_TOP_K: usize = 3;
const SYSTEM_PROMPT: &str =
"You are a helpful assistant. Answer the user's question using the provided \
context when it is relevant; say so if the context does not contain an answer.";
fn build_prompt(query: &str, chunks: &[String]) -> String {
if chunks.is_empty() {
return format!("Question: {query}");
}
format!(
"Relevant context:\n{}\n\nAnswer using this context if helpful.\n\nQuestion: {query}",
chunks.join("\n")
)
}
pub fn build_store(rag_dir: &Path, embedder: Box<dyn Embedder>, top_k: usize) -> Result<RagStore> {
let store = RagStore::build(rag_dir, embedder, top_k)?;
eprintln!(
"docbot: indexed {} chunk(s) from {}",
store.len(),
rag_dir.display()
);
Ok(store)
}
pub async fn answer(store: &mut RagStore, query: &str, llm: &mut dyn LlmBackend) -> Result<String> {
let top_k = store.top_k;
let chunks = store.search(query, top_k);
let prompt = build_prompt(query, &chunks);
let (tx, mut rx) = mpsc::channel::<String>(CLAUSE_CAP);
let cancel = CancellationToken::new();
let turn = llm.stream_reply(&prompt, tx, cancel);
tokio::pin!(turn);
let mut reply = String::new();
let mut stream_result: Option<Result<()>> = None;
loop {
tokio::select! {
biased;
result = &mut turn => {
stream_result = Some(result);
while let Ok(clause) = rx.try_recv() {
reply.push_str(&clause);
}
break;
}
clause = rx.recv() => match clause {
Some(clause) => reply.push_str(&clause),
None => break,
},
}
}
match stream_result {
Some(Ok(())) | None => Ok(reply),
Some(Err(err)) => Err(err),
}
}
fn list_documents(rag_dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
walk_docs(rag_dir, &mut out);
out.sort();
out
}
fn walk_docs(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(ft) = entry.file_type() else {
continue;
};
if ft.is_dir() {
walk_docs(&path, out);
} else if ft.is_file() && is_doc(&path) {
out.push(path);
}
}
}
fn is_doc(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("txt") | Some("md")
)
}
async fn repl(store: &mut RagStore, llm: &mut LlmClient, rag_dir: &Path) -> Result<()> {
let stdin = io::stdin();
let mut lines = stdin.lock().lines();
loop {
print!("Ask> ");
let _ = io::stdout().flush();
let line = match lines.next() {
Some(Ok(line)) => line,
Some(Err(e)) => {
return Err(anyhow::anyhow!("stdin read error: {e}").into());
}
None => break, };
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "/quit" {
break;
}
if line == "/docs" {
let docs = list_documents(rag_dir);
if docs.is_empty() {
println!("(no .txt/.md documents found in {})", rag_dir.display());
} else {
println!("Indexed documents ({}):", docs.len());
for d in &docs {
println!(" - {}", d.display());
}
}
continue;
}
match answer(store, line, llm).await {
Ok(reply) => println!("Answer> {}\n", reply.trim()),
Err(e) => eprintln!("error: {e}"),
}
}
Ok(())
}
fn main() -> Result<()> {
let rag_dir = std::env::var("SKADOOSH_RAG_DIR").map_err(|_| {
anyhow::anyhow!(
"SKADOOSH_RAG_DIR is required: point it at a directory of .txt/.md documents to index"
)
})?;
let base_url = std::env::var("SKADOOSH_BASE_URL")
.unwrap_or_else(|_| "http://localhost:11434/v1".to_string());
let model = std::env::var("SKADOOSH_MODEL").unwrap_or_else(|_| "llama3.2".to_string());
let api_key = std::env::var("SKADOOSH_API_KEY").ok();
let rag_model =
std::env::var("SKADOOSH_RAG_MODEL").unwrap_or_else(|_| DEFAULT_RAG_MODEL.to_string());
let top_k = std::env::var("SKADOOSH_RAG_TOP_K")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_TOP_K);
let rag_dir = PathBuf::from(&rag_dir);
let rag_model = PathBuf::from(&rag_model);
let vocab = OnnxEmbedder::companion_vocab(&rag_model);
if !rag_model.is_file() || !vocab.is_file() {
return Err(anyhow::anyhow!(
"RAG embedding model not found at {} (or its companion vocab {}). \
Run `./scripts/download_models.sh --with-rag` to fetch all-MiniLM-L6-v2.",
rag_model.display(),
vocab.display(),
)
.into());
}
let embedder = OnnxEmbedder::load(&rag_model, &vocab, DEFAULT_MAX_SEQ_LEN)?;
let mut store = build_store(&rag_dir, Box::new(embedder), top_k)?;
let mut llm = LlmClient::new(&base_url, &model, SYSTEM_PROMPT, 8, api_key);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow::anyhow!("failed to start runtime: {e}"))?;
rt.block_on(repl(&mut store, &mut llm, &rag_dir))
}