1use 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
114pub 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}