1use notedthat_core::{
6 AccessPolicy, Error, KbDetails, KbManifest, KbSlug, Storage, TenantSlug, derive_bucket_name,
7 validate_bucket_name,
8};
9use notedthat_indexer::{ProvisionError, QdrantProvisioner};
10use std::collections::BTreeMap;
11use std::sync::Arc;
12use std::time::{SystemTime, UNIX_EPOCH};
13use tracing::{info, warn};
14
15#[derive(Debug, Default)]
18pub struct ProvisionedKbs {
19 pub access_policies: BTreeMap<String, Arc<AccessPolicy>>,
21 pub details: BTreeMap<String, KbDetails>,
23}
24
25pub async fn provision_kbs(
38 storage: &dyn Storage,
39 tenant: &TenantSlug,
40 kbs: &[KbSlug],
41 provisioner: &QdrantProvisioner,
42 embedder_model: &str,
43 embedder_dim: u32,
44 embedder_endpoint_hint: Option<&str>,
45) -> Result<ProvisionedKbs, Error> {
46 let mut snapshot = ProvisionedKbs::default();
47 for kb in kbs {
48 validate_bucket_name(tenant, kb)?;
49
50 let bucket = derive_bucket_name(tenant, kb);
51 info!(kb = %kb.as_str(), bucket = %bucket, "provisioning KB");
52
53 storage.ensure_bucket(kb).await?;
54
55 let mut manifest = match storage.read_manifest(kb).await {
56 Ok(manifest) => {
57 if manifest.kb_slug.as_str() == kb.as_str() {
58 info!(kb = %kb.as_str(), "manifest OK");
59 manifest
60 } else {
61 warn!(
62 kb = %kb.as_str(),
63 manifest_kb = %manifest.kb_slug.as_str(),
64 "manifest kb_slug mismatch — overwriting with fresh manifest"
65 );
66 let fresh = KbManifest::new_v1(tenant, kb, kb.as_str(), current_unix_ts());
67 storage.write_manifest(kb, &fresh).await?;
68 fresh
69 }
70 }
71 Err(e) if e.is_not_found() => {
72 info!(kb = %kb.as_str(), "writing initial manifest");
73 let fresh = KbManifest::new_v1(tenant, kb, kb.as_str(), current_unix_ts());
74 storage.write_manifest(kb, &fresh).await?;
75 fresh
76 }
77 Err(e) => return Err(Error::Storage(e)),
78 };
79
80 manifest.validate()?;
83
84 let recorded =
88 QdrantProvisioner::cross_check_manifest(&manifest, embedder_model, embedder_dim)
89 .map_err(|err| provision_error(&err))?;
90 provisioner
91 .ensure_collection(kb, u64::from(embedder_dim))
92 .await
93 .map_err(|err| qdrant_provision_error(kb, &err))?;
94 if recorded.is_none() {
95 manifest.embedding = Some(QdrantProvisioner::manifest_embedding_from_env(
96 embedder_model.to_string(),
97 embedder_dim,
98 embedder_endpoint_hint.map(str::to_string),
99 ));
100 storage.write_manifest(kb, &manifest).await?;
101 info!(kb = %kb.as_str(), "qdrant collection provisioned and manifest embedding recorded");
102 }
103 if manifest.access.is_empty() {
104 warn!(kb = %kb.as_str(), "ACCESS_RULES_EMPTY");
109 }
110 info!(
111 kb = %kb.as_str(),
112 rules = manifest.access.rules().len(),
113 anonymous = manifest.access.visible_in_listing(¬edthat_core::Principal::Anyone),
114 "access policy loaded"
115 );
116 snapshot
117 .access_policies
118 .insert(kb.as_str().to_string(), Arc::new(manifest.access.clone()));
119 snapshot
120 .details
121 .insert(kb.as_str().to_string(), manifest.details());
122 }
123 Ok(snapshot)
124}
125
126fn provision_error(err: &ProvisionError) -> Error {
127 Error::Config {
128 message: err.to_string(),
129 }
130}
131
132fn qdrant_provision_error(kb: &KbSlug, err: &ProvisionError) -> Error {
136 Error::Config {
137 message: format!(
138 "failed to provision the qdrant collection for knowledge base '{}' via {}: {err}",
139 kb.as_str(),
140 notedthat_core::setting("NOTEDTHAT_QDRANT_URL"),
141 ),
142 }
143}
144
145fn current_unix_ts() -> i64 {
148 let seconds = SystemTime::now()
149 .duration_since(UNIX_EPOCH)
150 .unwrap_or_default()
151 .as_secs();
152 i64::try_from(seconds).unwrap_or(i64::MAX)
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use notedthat_api_http::testing::InMemoryStorage;
159 use notedthat_indexer::testing::InMemoryVectorStore;
160
161 fn provisioner() -> QdrantProvisioner {
162 QdrantProvisioner::new(std::sync::Arc::new(InMemoryVectorStore::new()))
163 }
164
165 #[tokio::test]
166 async fn test_provision_kbs_happy_path() {
167 let storage = InMemoryStorage::default();
168 let tenant = TenantSlug::default();
169 let kbs = vec![
170 KbSlug::try_new("notes").unwrap(),
171 KbSlug::try_new("docs").unwrap(),
172 ];
173
174 provision_kbs(
175 &storage,
176 &tenant,
177 &kbs,
178 &provisioner(),
179 "test-model",
180 3,
181 Some("http://embedder.example"),
182 )
183 .await
184 .unwrap();
185
186 assert_eq!(
187 storage
188 .read_manifest(&kbs[0])
189 .await
190 .unwrap()
191 .kb_slug
192 .as_str(),
193 "notes"
194 );
195 assert_eq!(
196 storage
197 .read_manifest(&kbs[1])
198 .await
199 .unwrap()
200 .kb_slug
201 .as_str(),
202 "docs"
203 );
204 }
205
206 #[tokio::test]
207 async fn test_provision_kbs_idempotent() {
208 let storage = InMemoryStorage::default();
209 let tenant = TenantSlug::default();
210 let kbs = vec![KbSlug::try_new("notes").unwrap()];
211
212 provision_kbs(
213 &storage,
214 &tenant,
215 &kbs,
216 &provisioner(),
217 "test-model",
218 3,
219 Some("http://embedder.example"),
220 )
221 .await
222 .unwrap();
223 provision_kbs(
224 &storage,
225 &tenant,
226 &kbs,
227 &provisioner(),
228 "test-model",
229 3,
230 Some("http://embedder.example"),
231 )
232 .await
233 .unwrap();
234
235 let manifest = storage.read_manifest(&kbs[0]).await.unwrap();
236 assert_eq!(manifest.kb_slug.as_str(), "notes");
237 assert_eq!(manifest.manifest_version, KbManifest::CURRENT_VERSION);
238 }
239
240 #[tokio::test]
241 async fn test_provision_kbs_writes_initial_manifest() {
242 let storage = InMemoryStorage::default();
243 let tenant = TenantSlug::default();
244 let kb = KbSlug::try_new("mykb").unwrap();
245 let kbs = vec![kb.clone()];
246
247 provision_kbs(
248 &storage,
249 &tenant,
250 &kbs,
251 &provisioner(),
252 "test-model",
253 3,
254 Some("http://embedder.example"),
255 )
256 .await
257 .unwrap();
258
259 let manifest = storage.read_manifest(&kb).await.unwrap();
260 assert_eq!(manifest.kb_slug.as_str(), "mykb");
261 assert_eq!(manifest.tenant_slug.as_str(), "default");
262 assert_eq!(manifest.manifest_version, KbManifest::CURRENT_VERSION);
263 assert!(manifest.created_at > 0);
264 }
265
266 #[tokio::test]
267 async fn an_unreachable_vector_store_refuses_startup_and_names_the_setting() {
268 let storage = InMemoryStorage::default();
269 let tenant = TenantSlug::default();
270 let kb = KbSlug::try_new("notes").unwrap();
271 let store = InMemoryVectorStore::new();
272 store.set_reachable(false);
273
274 let error = provision_kbs(
275 &storage,
276 &tenant,
277 std::slice::from_ref(&kb),
278 &QdrantProvisioner::new(std::sync::Arc::new(store)),
279 "test-model",
280 3,
281 Some("http://embedder.example"),
282 )
283 .await
284 .expect_err("an unreachable vector store is fatal (D39)");
285
286 let message = error.to_string();
287 for needle in ["'notes'", "NOTEDTHAT_QDRANT_URL", "--qdrant-url"] {
288 assert!(message.contains(needle), "{message:?} should name {needle}");
289 }
290
291 let manifest = storage.read_manifest(&kb).await.unwrap();
294 assert!(manifest.embedding.is_none());
295 }
296}