Skip to main content

convergio_knowledge/
seed.rs

1//! Baseline seed — ingest ALL existing knowledge into the vector store.
2//!
3//! Sources: AGENTS.md learnings, ADR decisions,
4//! knowledge_base table, completed plans, and CONSTITUTION.md rules.
5//! Idempotent: uses content-hash source_ids to skip duplicates.
6
7use std::collections::{HashMap, HashSet};
8use std::path::PathBuf;
9
10use convergio_db::pool::{ConnPool, PooledConn};
11use tracing::{info, warn};
12
13use crate::seed_parsers::{parse_adr_files, parse_constitution, parse_learnings};
14use crate::store::LanceVectorStore;
15
16#[derive(Debug, Clone)]
17pub struct SeedReport {
18    pub total: usize,
19    pub by_source: Vec<(String, usize)>,
20    pub skipped: usize,
21    pub errors: Vec<String>,
22}
23
24type Entry = (String, String);
25
26fn content_hash(s: &str) -> String {
27    let h = s
28        .bytes()
29        .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
30    format!("seed-{h:016x}")
31}
32
33fn repo_root() -> Option<PathBuf> {
34    if let Ok(p) = std::env::var("CONVERGIO_REPO_ROOT") {
35        return Some(PathBuf::from(p));
36    }
37    std::process::Command::new("git")
38        .args(["rev-parse", "--show-toplevel"])
39        .output()
40        .ok()
41        .and_then(|o| String::from_utf8(o.stdout).ok())
42        .map(|s| PathBuf::from(s.trim()))
43}
44
45fn read_file(root: &std::path::Path, rel: &str) -> Option<String> {
46    let path = root.join(rel);
47    std::fs::read_to_string(&path)
48        .inspect_err(|e| warn!(path = %path.display(), error = %e, "seed: skipping"))
49        .ok()
50}
51
52fn has_table(pool: &ConnPool, name: &str) -> Option<PooledConn> {
53    let conn = pool.get().ok()?;
54    let sql = format!("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{name}'");
55    let exists: bool = conn
56        .query_row(&sql, [], |r| r.get::<_, i64>(0).map(|c| c > 0))
57        .unwrap_or(false);
58    if exists {
59        Some(conn)
60    } else {
61        None
62    }
63}
64
65fn seed_from_kb(pool: &ConnPool) -> Vec<Entry> {
66    let Some(conn) = has_table(pool, "knowledge_base") else {
67        info!("seed: knowledge_base table not found, skipping");
68        return vec![];
69    };
70    let Ok(mut stmt) =
71        conn.prepare("SELECT domain, title, content FROM knowledge_base ORDER BY title")
72    else {
73        return vec![];
74    };
75    stmt.query_map([], |r| {
76        let (d, t, c): (String, String, String) = (r.get(0)?, r.get(1)?, r.get(2)?);
77        Ok((
78            format!(
79                "[{d}] {t}
80
81{c}"
82            ),
83            "kb".to_string(),
84        ))
85    })
86    .map(|rows| rows.filter_map(|r| r.ok()).collect())
87    .unwrap_or_default()
88}
89
90fn seed_from_plans(pool: &ConnPool) -> Vec<Entry> {
91    let Some(conn) = has_table(pool, "plans") else {
92        return vec![];
93    };
94    let Ok(mut stmt) =
95        conn.prepare("SELECT id, name, objective FROM plans WHERE status='done' ORDER BY id")
96    else {
97        return vec![];
98    };
99    stmt.query_map([], |r| {
100        let (id, n, o): (i64, String, String) = (r.get(0)?, r.get(1)?, r.get(2)?);
101        Ok((
102            format!(
103                "Plan #{id}: {n}
104
105{o}"
106            ),
107            "task".to_string(),
108        ))
109    })
110    .map(|rows| rows.filter_map(|r| r.ok()).collect())
111    .unwrap_or_default()
112}
113
114/// Ingest all known sources into the vector store. Idempotent.
115pub async fn seed_baseline(store: &LanceVectorStore, pool: &ConnPool) -> SeedReport {
116    let mut report = SeedReport {
117        total: 0,
118        by_source: Vec::new(),
119        skipped: 0,
120        errors: Vec::new(),
121    };
122    let existing: HashSet<String> = store.all_source_ids().await.unwrap_or_default();
123    info!(
124        existing = existing.len(),
125        "seed: loaded existing source_ids"
126    );
127
128    let root = match repo_root() {
129        Some(r) => r,
130        None => {
131            report.errors.push("cannot determine repo root".into());
132            return report;
133        }
134    };
135
136    let mut all: Vec<(String, String, &str)> = Vec::new();
137    macro_rules! collect {
138        ($items:expr, $label:expr) => {{
139            let items = $items;
140            info!(count = items.len(), concat!("seed: ", $label));
141            all.extend(items.into_iter().map(|(c, t)| (c, t, $label)));
142        }};
143    }
144    if let Some(text) = read_file(&root, "AGENTS.md") {
145        collect!(parse_learnings(&text), "agents-learnings");
146    }
147    collect!(parse_adr_files(&root), "adr");
148    collect!(seed_from_kb(pool), "knowledge-base");
149    collect!(seed_from_plans(pool), "plans");
150    if let Some(text) = read_file(&root, "CONSTITUTION.md") {
151        collect!(parse_constitution(&text), "constitution");
152    }
153
154    let mut counts: HashMap<&str, usize> = HashMap::new();
155    for (content, source_type, label) in &all {
156        let sid = content_hash(content);
157        if existing.contains(&sid) {
158            report.skipped += 1;
159            continue;
160        }
161        let emb = crate::embedder::embed(content).await;
162        match store
163            .insert(content, source_type, &sid, None, None, None, "org", &emb)
164            .await
165        {
166            Ok(_) => {
167                *counts.entry(label).or_insert(0) += 1;
168                report.total += 1;
169            }
170            Err(e) => report.errors.push(format!("[{label}] {e}")),
171        }
172    }
173    report.by_source = counts
174        .into_iter()
175        .map(|(k, v)| (k.to_string(), v))
176        .collect();
177    report.by_source.sort_by(|a, b| a.0.cmp(&b.0));
178    info!(
179        total = report.total,
180        skipped = report.skipped,
181        "seed_baseline complete"
182    );
183    report
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn content_hash_deterministic_and_differs() {
192        assert_eq!(content_hash("hello"), content_hash("hello"));
193        assert!(content_hash("hello").starts_with("seed-"));
194        assert_ne!(content_hash("hello"), content_hash("world"));
195    }
196}