use crate::db;
use crate::ingest::ingest_file;
use crate::normalize::slugify;
use crate::vector::VectorStore;
use anyhow::Result;
use libsql::{Connection, params};
use std::io::Write;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
pub async fn sync_graph_to_markdown(
conn: &Connection,
store: &VectorStore,
embedder: &impl crate::embed::EmbeddingProvider,
) -> Result<usize> {
let paths = crate::paths::AsobiPaths::resolve();
let topics_dir = paths.topics_dir;
if !topics_dir.exists() {
std::fs::create_dir_all(&topics_dir)?;
}
let graph = db::read_graph_eager(conn).await?;
let mut count = 0;
for entity in graph.entities {
let slug = slugify(&entity.name);
let file_path = topics_dir.join(format!("{}.md", slug));
let mut content = String::new();
content.push_str("---\n");
content.push_str(&format!("title: {}\n", entity.name));
content.push_str(&format!("type: {}\n", entity.entity_type));
content.push_str(&format!("slug: {}\n", slug));
content.push_str("---\n\n");
if !entity.observations.is_empty() {
content.push_str("## Observations\n\n");
let mut unique_obs: Vec<String> = entity
.observations
.into_iter()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
unique_obs.sort();
for obs in unique_obs {
content.push_str(&format!("* {}\n", obs));
}
content.push('\n');
}
let relations: Vec<_> = graph
.relations
.iter()
.filter(|r| r.from == entity.name || r.to == entity.name)
.collect();
if !relations.is_empty() {
content.push_str("## Relations\n\n");
for rel in relations {
if rel.from == entity.name {
content.push_str(&format!(
"* {} [[{}]]\n",
rel.relation_type,
slugify(&rel.to)
));
} else {
content.push_str(&format!(
"* [[{}]] is {} of this\n",
slugify(&rel.from),
rel.relation_type
));
}
}
}
let mut file = std::fs::File::create(&file_path)?;
file.write_all(content.as_bytes())?;
ingest_file(&file_path, conn, store, embedder).await?;
count += 1;
}
Ok(count)
}
pub fn prune_old_sessions(topics_root: &str, days: u32) -> Result<usize> {
let sessions_dir = PathBuf::from(topics_root).join("sessions");
if !sessions_dir.exists() {
return Ok(0);
}
let cutoff = SystemTime::now() - Duration::from_secs(days as u64 * 86400);
let mut pruned = 0;
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
let meta = entry.metadata()?;
if meta.modified()? < cutoff {
std::fs::remove_file(path)?;
pruned += 1;
}
}
Ok(pruned)
}
pub async fn find_duplicate_clusters(
store: &crate::vector::VectorStore,
conn: &libsql::Connection,
threshold: f32,
) -> anyhow::Result<Vec<Vec<String>>> {
let mut rows = conn
.query(crate::constant::SQL_SELECT_ALL_TOPIC_IDS, ())
.await?;
let mut topic_ids = Vec::new();
while let Some(row) = rows.next().await? {
topic_ids.push(row.get::<String>(0)?);
}
let mut clusters: Vec<Vec<String>> = Vec::new();
let mut clustered: std::collections::HashSet<String> = std::collections::HashSet::new();
for id in &topic_ids {
if clustered.contains(id) {
continue;
}
let mut rows = conn
.query(
"SELECT embedding FROM chunks WHERE topic_id = ?1 LIMIT 1",
params![id.clone()],
)
.await?;
if let Some(row) = rows.next().await? {
let blob: Vec<u8> = row.get(0)?;
let vector: Vec<f32> = blob
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
let similar = store.search(&vector, 10).await?;
let mut cluster = vec![id.clone()];
for s in similar {
if s.score >= threshold && s.topic_id != *id && !clustered.contains(&s.topic_id) {
cluster.push(s.topic_id.clone());
clustered.insert(s.topic_id.clone());
}
}
if cluster.len() > 1 {
clustered.insert(id.clone());
clusters.push(cluster);
}
}
}
Ok(clusters)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_prune_removes_old_session_files() {
let dir = tempdir().unwrap();
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let old_path = sessions_dir.join("2020-01-01-0000.md");
std::fs::File::create(&old_path)
.unwrap()
.write_all(b"old")
.unwrap();
let old_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1577836800);
filetime::set_file_mtime(&old_path, filetime::FileTime::from_system_time(old_time))
.unwrap();
let count = prune_old_sessions(dir.path().to_str().unwrap(), 90).unwrap();
assert_eq!(count, 1);
assert!(!old_path.exists());
}
}