Skip to main content

khive_pack_knowledge/
pack.rs

1//! `KnowledgePack` struct, factory, and `PackRuntime` impl.
2
3use std::sync::Mutex;
4
5use async_trait::async_trait;
6use serde_json::Value;
7use uuid::Uuid;
8
9use khive_brain_core::SectionPosteriorState;
10use khive_runtime::pack::{PackByIdResolver, PackRuntime};
11use khive_runtime::{KhiveRuntime, NamespaceToken, Resolved, RuntimeError, VerbRegistry};
12use khive_storage::types::{SqlStatement, SqlValue};
13use khive_types::{HandlerDef, Pack};
14
15use crate::knowledge::util::{atom_from_row, atom_to_json, domain_from_row, domain_to_json};
16use crate::knowledge::vamana;
17use crate::knowledge::KnowledgeHandlers;
18use crate::vocab::KNOWLEDGE_HANDLERS;
19
20/// Knowledge corpus pack — atoms, domains, TF-IDF search, fold, import, and KG concept verbs.
21pub struct KnowledgePack {
22    pub(crate) runtime: KhiveRuntime,
23    pub(crate) ann: vamana::SharedAnn,
24    pub(crate) section_posteriors: Mutex<SectionPosteriorState>,
25    /// Explicit brain profile ID from config (ADR-035 §Brain profile configuration).
26    ///
27    /// Tier-1 of the 3-tier feedback resolution: when set, `knowledge.feedback` directs
28    /// feedback to this profile via `brain.feedback`. When absent, tier-2
29    /// (namespace-bound profile) and tier-3 (global prior) are tried in order.
30    pub(crate) brain_profile: Option<String>,
31}
32
33impl Pack for KnowledgePack {
34    const NAME: &'static str = "knowledge";
35    const NOTE_KINDS: &'static [&'static str] = &[];
36    const ENTITY_KINDS: &'static [&'static str] = &[];
37    const HANDLERS: &'static [HandlerDef] = &KNOWLEDGE_HANDLERS;
38    const REQUIRES: &'static [&'static str] = &["kg"];
39}
40
41impl KnowledgePack {
42    /// Create a new pack bound to the given runtime, initializing a shared ANN index.
43    pub fn new(runtime: KhiveRuntime) -> Self {
44        let brain_profile = runtime.config().brain_profile.clone();
45        Self {
46            runtime,
47            ann: vamana::new_shared(),
48            section_posteriors: Mutex::new(SectionPosteriorState::new()),
49            brain_profile,
50        }
51    }
52}
53
54struct KnowledgePackFactory;
55
56impl khive_runtime::PackFactory for KnowledgePackFactory {
57    fn name(&self) -> &'static str {
58        "knowledge"
59    }
60
61    fn requires(&self) -> &'static [&'static str] {
62        &["kg"]
63    }
64
65    fn create(&self, runtime: KhiveRuntime) -> Box<dyn khive_runtime::PackRuntime> {
66        Box::new(KnowledgePack::new(runtime))
67    }
68
69    fn create_resolver(
70        &self,
71        runtime: KhiveRuntime,
72    ) -> Option<Box<dyn khive_runtime::PackByIdResolver>> {
73        Some(Box::new(KnowledgePack::new(runtime)))
74    }
75}
76
77inventory::submit! { khive_runtime::PackRegistration(&KnowledgePackFactory) }
78
79#[async_trait]
80impl PackRuntime for KnowledgePack {
81    fn name(&self) -> &str {
82        <KnowledgePack as Pack>::NAME
83    }
84
85    fn note_kinds(&self) -> &'static [&'static str] {
86        <KnowledgePack as Pack>::NOTE_KINDS
87    }
88
89    fn entity_kinds(&self) -> &'static [&'static str] {
90        <KnowledgePack as Pack>::ENTITY_KINDS
91    }
92
93    fn handlers(&self) -> &'static [HandlerDef] {
94        &KNOWLEDGE_HANDLERS
95    }
96
97    fn requires(&self) -> &'static [&'static str] {
98        <KnowledgePack as Pack>::REQUIRES
99    }
100
101    async fn warm(&self) {
102        crate::knowledge::vamana::warm_known_snapshots(&self.runtime, &self.ann).await;
103        if !self.runtime.default_embedder_name().is_empty() {
104            let runtime = self.runtime.clone();
105            tokio::spawn(async move {
106                let _ = runtime.embed("__khive_knowledge_warm__").await;
107            });
108        }
109    }
110
111    async fn dispatch(
112        &self,
113        verb: &str,
114        params: Value,
115        registry: &VerbRegistry,
116        token: &NamespaceToken,
117    ) -> Result<Value, RuntimeError> {
118        match verb {
119            "knowledge.upsert_atoms" => {
120                KnowledgeHandlers::upsert_atoms(&self.runtime, token, params).await
121            }
122            "knowledge.upsert_domains" => {
123                KnowledgeHandlers::upsert_domains(&self.runtime, token, params).await
124            }
125            "knowledge.get" => KnowledgeHandlers::get(&self.runtime, token, params).await,
126            "knowledge.list" => KnowledgeHandlers::list(&self.runtime, token, params).await,
127            "knowledge.delete_atoms" => {
128                KnowledgeHandlers::delete_atoms(&self.runtime, token, params).await
129            }
130            "knowledge.stats" => KnowledgeHandlers::stats(&self.runtime, token, params).await,
131            "knowledge.index" => {
132                KnowledgeHandlers::index(&self.runtime, token, params, &self.ann, None).await
133            }
134            "knowledge.fold" => KnowledgeHandlers::fold(&self.runtime, token, params).await,
135            "knowledge.search" => {
136                KnowledgeHandlers::search(&self.runtime, token, params, &self.ann).await
137            }
138            "knowledge.suggest" => {
139                KnowledgeHandlers::suggest(&self.runtime, token, params, &self.ann).await
140            }
141            "knowledge.compose" => {
142                let type_weights = self.resolve_compose_type_weights(registry, token).await;
143                KnowledgeHandlers::compose(&self.runtime, token, params, &self.ann, type_weights)
144                    .await
145            }
146            "knowledge.edit" => {
147                KnowledgeHandlers::edit(&self.runtime, token, params, &self.ann).await
148            }
149            "knowledge.import" => {
150                KnowledgeHandlers::import(&self.runtime, token, params, &self.ann).await
151            }
152            "knowledge.challenge" => {
153                KnowledgeHandlers::challenge(&self.runtime, token, params).await
154            }
155            "knowledge.adjudicate" => {
156                KnowledgeHandlers::adjudicate(&self.runtime, token, params).await
157            }
158            "knowledge.learn" => self.handle_learn(token, params).await,
159            "knowledge.cite" => self.handle_cite(token, params).await,
160            "knowledge.topic" => self.handle_topic(token, params).await,
161            "knowledge.feedback" => self.handle_feedback(token, params, registry).await,
162            _ => Err(RuntimeError::InvalidInput(format!(
163                "knowledge pack does not handle verb {verb:?}"
164            ))),
165        }
166    }
167}
168
169#[async_trait]
170impl PackByIdResolver for KnowledgePack {
171    /// Resolve a live knowledge record by UUID.
172    ///
173    /// Queries `knowledge_domains` first (canonical record), then
174    /// `knowledge_atoms`. The domain mirror atom shares the domain's UUID
175    /// (crud.rs:279/300) so domains must win over the mirror.
176    async fn resolve_by_id(&self, id: Uuid) -> Result<Option<Resolved>, RuntimeError> {
177        let sql = self.runtime.sql();
178        let id_str = id.to_string();
179
180        let mut reader = sql
181            .reader()
182            .await
183            .map_err(|e| RuntimeError::Internal(format!("knowledge resolve_by_id reader: {e}")))?;
184
185        // 1. Check knowledge_domains first (canonical over the mirror atom).
186        let domain_row = reader
187            .query_row(SqlStatement {
188                sql: "SELECT id, namespace, slug, name, description, tags, members, \
189                      created_at, updated_at, deleted_at \
190                      FROM knowledge_domains WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
191                    .into(),
192                params: vec![SqlValue::Text(id_str.clone())],
193                label: Some("knowledge.resolve_by_id.domain".into()),
194            })
195            .await
196            .map_err(|e| {
197                RuntimeError::Internal(format!("knowledge resolve_by_id domain query: {e}"))
198            })?;
199
200        if let Some(row) = domain_row {
201            if let Some(domain) = domain_from_row(&row) {
202                return Ok(Some(Resolved::PackRecord {
203                    pack: "knowledge".into(),
204                    kind: "domain".into(),
205                    data: domain_to_json(&domain),
206                }));
207            }
208        }
209
210        // 2. Check knowledge_atoms.
211        let atom_row = reader
212            .query_row(SqlStatement {
213                sql: "SELECT id, namespace, slug, name, content, tags, properties, \
214                      status, finalized, source_uri, source_type, created_at, updated_at, deleted_at \
215                      FROM knowledge_atoms WHERE id = ?1 AND deleted_at IS NULL LIMIT 1"
216                    .into(),
217                params: vec![SqlValue::Text(id_str)],
218                label: Some("knowledge.resolve_by_id.atom".into()),
219            })
220            .await
221            .map_err(|e| {
222                RuntimeError::Internal(format!("knowledge resolve_by_id atom query: {e}"))
223            })?;
224
225        if let Some(row) = atom_row {
226            if let Some(atom) = atom_from_row(&row) {
227                return Ok(Some(Resolved::PackRecord {
228                    pack: "knowledge".into(),
229                    kind: "atom".into(),
230                    data: atom_to_json(&atom),
231                }));
232            }
233        }
234
235        Ok(None)
236    }
237
238    /// Resolve a knowledge record including already-soft-deleted records.
239    ///
240    /// Used by the hard-delete path to locate tombstoned records.
241    async fn resolve_by_id_including_deleted(
242        &self,
243        id: Uuid,
244    ) -> Result<Option<Resolved>, RuntimeError> {
245        let sql = self.runtime.sql();
246        let id_str = id.to_string();
247
248        let mut reader = sql.reader().await.map_err(|e| {
249            RuntimeError::Internal(format!(
250                "knowledge resolve_by_id_including_deleted reader: {e}"
251            ))
252        })?;
253
254        let domain_row = reader
255            .query_row(SqlStatement {
256                sql: "SELECT id, namespace, slug, name, description, tags, members, \
257                      created_at, updated_at, deleted_at \
258                      FROM knowledge_domains WHERE id = ?1 LIMIT 1"
259                    .into(),
260                params: vec![SqlValue::Text(id_str.clone())],
261                label: Some("knowledge.resolve_by_id_incl_deleted.domain".into()),
262            })
263            .await
264            .map_err(|e| {
265                RuntimeError::Internal(format!(
266                    "knowledge resolve_by_id_including_deleted domain query: {e}"
267                ))
268            })?;
269
270        if let Some(row) = domain_row {
271            if let Some(domain) = domain_from_row(&row) {
272                return Ok(Some(Resolved::PackRecord {
273                    pack: "knowledge".into(),
274                    kind: "domain".into(),
275                    data: domain_to_json(&domain),
276                }));
277            }
278        }
279
280        let atom_row = reader
281            .query_row(SqlStatement {
282                sql: "SELECT id, namespace, slug, name, content, tags, properties, \
283                      status, finalized, source_uri, source_type, created_at, updated_at, deleted_at \
284                      FROM knowledge_atoms WHERE id = ?1 LIMIT 1"
285                    .into(),
286                params: vec![SqlValue::Text(id_str)],
287                label: Some("knowledge.resolve_by_id_incl_deleted.atom".into()),
288            })
289            .await
290            .map_err(|e| {
291                RuntimeError::Internal(format!(
292                    "knowledge resolve_by_id_including_deleted atom query: {e}"
293                ))
294            })?;
295
296        if let Some(row) = atom_row {
297            if let Some(atom) = atom_from_row(&row) {
298                return Ok(Some(Resolved::PackRecord {
299                    pack: "knowledge".into(),
300                    kind: "atom".into(),
301                    data: atom_to_json(&atom),
302                }));
303            }
304        }
305
306        Ok(None)
307    }
308
309    /// Delete a knowledge domain or atom by UUID.
310    ///
311    /// Soft-delete by default (`hard=false`): sets `deleted_at = now()`.
312    /// For domains, also tombstones the mirror atom in `knowledge_atoms`.
313    /// Hard-delete (`hard=true`): permanently removes rows from the table(s).
314    async fn delete_by_id(&self, id: Uuid, hard: bool) -> Result<serde_json::Value, RuntimeError> {
315        let sql = self.runtime.sql();
316        let id_str = id.to_string();
317
318        // First determine the kind (including deleted rows so hard-delete can find tombstones).
319        let kind = match self.resolve_by_id_including_deleted(id).await? {
320            Some(Resolved::PackRecord { kind, .. }) => kind,
321            _ => {
322                return Err(RuntimeError::NotFound(format!(
323                    "knowledge record not found: {id}"
324                )));
325            }
326        };
327
328        let now_us = std::time::SystemTime::now()
329            .duration_since(std::time::UNIX_EPOCH)
330            .unwrap_or_default()
331            .as_micros() as i64;
332
333        let mut writer = sql
334            .writer()
335            .await
336            .map_err(|e| RuntimeError::Internal(format!("knowledge delete_by_id writer: {e}")))?;
337
338        match kind.as_str() {
339            "domain" => {
340                if hard {
341                    // Hard-delete: sections → mirror atom → domain (all in one transaction).
342                    // knowledge_sections has a FK to knowledge_atoms(id) without ON DELETE
343                    // CASCADE, so sections must go before the atom row.
344                    writer
345                        .execute_batch(vec![
346                            SqlStatement {
347                                sql: "DELETE FROM knowledge_sections WHERE atom_id = ?1".into(),
348                                params: vec![SqlValue::Text(id_str.clone())],
349                                label: Some(
350                                    "knowledge.delete_by_id.domain_mirror_sections.hard".into(),
351                                ),
352                            },
353                            SqlStatement {
354                                sql: "DELETE FROM knowledge_atoms WHERE id = ?1".into(),
355                                params: vec![SqlValue::Text(id_str.clone())],
356                                label: Some("knowledge.delete_by_id.domain_mirror.hard".into()),
357                            },
358                            SqlStatement {
359                                sql: "DELETE FROM knowledge_domains WHERE id = ?1".into(),
360                                params: vec![SqlValue::Text(id_str.clone())],
361                                label: Some("knowledge.delete_by_id.domain.hard".into()),
362                            },
363                        ])
364                        .await
365                        .map_err(|e| {
366                            RuntimeError::Internal(format!(
367                                "knowledge delete_by_id domain hard: {e}"
368                            ))
369                        })?;
370                } else {
371                    writer
372                        .execute(SqlStatement {
373                            sql: "UPDATE knowledge_domains SET deleted_at = ?1 \
374                                  WHERE id = ?2 AND deleted_at IS NULL"
375                                .into(),
376                            params: vec![SqlValue::Integer(now_us), SqlValue::Text(id_str.clone())],
377                            label: Some("knowledge.delete_by_id.domain.soft".into()),
378                        })
379                        .await
380                        .map_err(|e| {
381                            RuntimeError::Internal(format!(
382                                "knowledge delete_by_id domain soft: {e}"
383                            ))
384                        })?;
385                    // Tombstone the mirror atom so FTS no longer surfaces it.
386                    writer
387                        .execute(SqlStatement {
388                            sql: "UPDATE knowledge_atoms SET deleted_at = ?1 \
389                                  WHERE id = ?2 AND deleted_at IS NULL"
390                                .into(),
391                            params: vec![SqlValue::Integer(now_us), SqlValue::Text(id_str.clone())],
392                            label: Some("knowledge.delete_by_id.domain_mirror.soft".into()),
393                        })
394                        .await
395                        .map_err(|e| {
396                            RuntimeError::Internal(format!(
397                                "knowledge delete_by_id domain mirror soft: {e}"
398                            ))
399                        })?;
400                }
401            }
402            "atom" => {
403                if hard {
404                    // Hard-delete: sections first (FK constraint), then the atom row.
405                    writer
406                        .execute_batch(vec![
407                            SqlStatement {
408                                sql: "DELETE FROM knowledge_sections WHERE atom_id = ?1".into(),
409                                params: vec![SqlValue::Text(id_str.clone())],
410                                label: Some("knowledge.delete_by_id.atom_sections.hard".into()),
411                            },
412                            SqlStatement {
413                                sql: "DELETE FROM knowledge_atoms WHERE id = ?1".into(),
414                                params: vec![SqlValue::Text(id_str.clone())],
415                                label: Some("knowledge.delete_by_id.atom.hard".into()),
416                            },
417                        ])
418                        .await
419                        .map_err(|e| {
420                            RuntimeError::Internal(format!("knowledge delete_by_id atom hard: {e}"))
421                        })?;
422                } else {
423                    writer
424                        .execute(SqlStatement {
425                            sql: "UPDATE knowledge_atoms SET deleted_at = ?1 \
426                                  WHERE id = ?2 AND deleted_at IS NULL"
427                                .into(),
428                            params: vec![SqlValue::Integer(now_us), SqlValue::Text(id_str.clone())],
429                            label: Some("knowledge.delete_by_id.atom.soft".into()),
430                        })
431                        .await
432                        .map_err(|e| {
433                            RuntimeError::Internal(format!("knowledge delete_by_id atom soft: {e}"))
434                        })?;
435                }
436            }
437            other => {
438                return Err(RuntimeError::Internal(format!(
439                    "knowledge delete_by_id: unexpected kind {other:?}"
440                )));
441            }
442        }
443
444        Ok(serde_json::json!({
445            "deleted": true,
446            "id": id_str,
447            "kind": kind,
448            "hard": hard,
449        }))
450    }
451}