#![allow(dead_code)]
#[path = "../examples/docbot.rs"]
mod docbot;
use std::collections::VecDeque;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use skadoosh::llm::LlmBackend;
use skadoosh::rag::Embedder;
use skadoosh::Result;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
static COUNTER: AtomicU64 = AtomicU64::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!(
"skadoosh-docbot-{}-{label}-{n}",
std::process::id()
));
std::fs::create_dir_all(&p).expect("create temp dir");
TempDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_doc(dir: &Path, name: &str, contents: &str) {
std::fs::write(dir.join(name), contents).expect("write doc");
}
struct BowEmbedder {
dim: usize,
}
impl BowEmbedder {
fn new(dim: usize) -> Self {
Self { dim }
}
fn vec_for(&self, text: &str) -> Vec<f32> {
let mut v = vec![0.0_f32; self.dim];
for word in text.to_lowercase().split_whitespace() {
let word = word.trim_matches(|c: char| !c.is_alphanumeric());
if word.is_empty() {
continue;
}
let mut h: u64 = 0xcbf29ce484222325;
for &b in word.as_bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x100000001b3);
}
v[(h as usize) % self.dim] += 1.0;
}
let mut norm = 0.0_f32;
for &x in &v {
norm += x * x;
}
norm = norm.sqrt();
if norm > 1e-12 {
for x in &mut v {
*x /= norm;
}
}
v
}
}
impl Embedder for BowEmbedder {
fn embed(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
Ok(texts.iter().map(|t| self.vec_for(t)).collect())
}
fn dim(&self) -> usize {
self.dim
}
}
struct ScriptedLlm {
script: Mutex<VecDeque<Vec<String>>>,
last_prompt: Mutex<Option<String>>,
}
impl ScriptedLlm {
fn new() -> Self {
Self {
script: Mutex::new(VecDeque::new()),
last_prompt: Mutex::new(None),
}
}
fn turn(self, clauses: &[&str]) -> Self {
self.script
.lock()
.expect("script lock")
.push_back(clauses.iter().map(|s| s.to_string()).collect());
self
}
fn last_prompt(&self) -> String {
self.last_prompt
.lock()
.expect("last_prompt lock")
.clone()
.unwrap_or_default()
}
}
impl LlmBackend for ScriptedLlm {
fn name(&self) -> &str {
"scripted-llm"
}
fn stream_reply<'a>(
&'a mut self,
user: &'a str,
clauses: mpsc::Sender<String>,
_cancel: CancellationToken,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
*self.last_prompt.lock().expect("last_prompt lock") = Some(user.to_string());
let reply: Vec<String> = self
.script
.lock()
.expect("script lock")
.pop_front()
.unwrap_or_else(|| vec!["(no scripted reply)".to_string()]);
Box::pin(async move {
for clause in reply {
let _ = clauses.send(clause).await;
}
Ok(())
})
}
fn clear_history(&mut self) {
self.script.lock().expect("script lock").clear();
}
}
fn fixtures() -> TempDir {
let dir = TempDir::new("fixtures");
write_doc(
dir.path(),
"cats.txt",
"Cats are popular pets. They eat fish and drink water.",
);
write_doc(
dir.path(),
"recipe.md",
"# Chocolate Cake\n\n\
A chocolate cake recipe uses flour, eggs, and sugar.\n\n\
Bake at 180 degrees Celsius for thirty minutes.",
);
dir
}
#[tokio::test]
async fn docbot_loads_chunks_and_answers_via_scripted_llm() {
let dir = fixtures();
let chunks = skadoosh::rag::load_documents(dir.path()).expect("load_documents");
assert_eq!(
chunks.len(),
4,
"2 files should chunk into 4 paragraphs: {chunks:?}"
);
assert!(
chunks.iter().any(|c| c.contains("fish")),
"cats chunk present"
);
assert!(
chunks.iter().any(|c| c.contains("Chocolate Cake")),
"recipe title chunk present"
);
let mut store =
docbot::build_store(dir.path(), Box::new(BowEmbedder::new(4096)), 1).expect("build_store");
assert_eq!(store.len(), 4, "store indexed all 4 chunks");
let mut llm =
ScriptedLlm::new().turn(&["Based on the documents, ", "cats eat fish and drink water."]);
let query = "What do cats eat and drink water";
let reply = docbot::answer(&mut store, query, &mut llm)
.await
.expect("answer");
assert!(reply.contains("Based on the documents"), "reply: {reply:?}");
assert!(reply.contains("fish"), "reply: {reply:?}");
let prompt = llm.last_prompt();
assert!(prompt.contains("Relevant context"), "prompt: {prompt:?}");
assert!(
prompt.contains("fish"),
"retrieved chunk injected into prompt: {prompt:?}"
);
assert!(
prompt.contains(query),
"the question is in the prompt: {prompt:?}"
);
assert!(
!prompt.to_lowercase().contains("chocolate"),
"irrelevant chunk must not leak into the prompt: {prompt:?}"
);
}