Skip to main content

roder_api/
knowledge.rs

1//! Project knowledge base contracts (roadmap phase 93).
2//!
3//! Knowledge documents are larger, titled, kind-tagged artifacts (requirements,
4//! decisions, research, runbooks, ...) with revisions and typed links — distinct
5//! from atomic memory records. Stores are provided by extensions; the first
6//! engine is the markdown-file-based `roder-ext-knowledge-md`.
7
8use std::fmt;
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13
14use crate::extension::KnowledgeStoreId;
15use crate::memory::MemoryScope;
16
17pub type KnowledgeDocId = String;
18
19/// Document kind. Extensible: unknown kinds round-trip through `Other`.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum KnowledgeKind {
22    Memory,
23    Requirement,
24    Decision,
25    Research,
26    Runbook,
27    Artifact,
28    Note,
29    Other(String),
30}
31
32impl KnowledgeKind {
33    pub const KNOWN: [&'static str; 7] = [
34        "memory",
35        "requirement",
36        "decision",
37        "research",
38        "runbook",
39        "artifact",
40        "note",
41    ];
42
43    pub fn as_str(&self) -> &str {
44        match self {
45            KnowledgeKind::Memory => "memory",
46            KnowledgeKind::Requirement => "requirement",
47            KnowledgeKind::Decision => "decision",
48            KnowledgeKind::Research => "research",
49            KnowledgeKind::Runbook => "runbook",
50            KnowledgeKind::Artifact => "artifact",
51            KnowledgeKind::Note => "note",
52            KnowledgeKind::Other(value) => value,
53        }
54    }
55
56    pub fn parse(value: &str) -> Self {
57        match value {
58            "memory" => KnowledgeKind::Memory,
59            "requirement" => KnowledgeKind::Requirement,
60            "decision" => KnowledgeKind::Decision,
61            "research" => KnowledgeKind::Research,
62            "runbook" => KnowledgeKind::Runbook,
63            "artifact" => KnowledgeKind::Artifact,
64            "note" => KnowledgeKind::Note,
65            other => KnowledgeKind::Other(other.to_string()),
66        }
67    }
68}
69
70impl fmt::Display for KnowledgeKind {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(self.as_str())
73    }
74}
75
76impl Serialize for KnowledgeKind {
77    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78        serializer.serialize_str(self.as_str())
79    }
80}
81
82impl<'de> Deserialize<'de> for KnowledgeKind {
83    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
84        let value = String::deserialize(deserializer)?;
85        Ok(KnowledgeKind::parse(&value))
86    }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum KnowledgeStatus {
92    Active,
93    Draft,
94    Superseded,
95    Archived,
96}
97
98impl KnowledgeStatus {
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            KnowledgeStatus::Active => "active",
102            KnowledgeStatus::Draft => "draft",
103            KnowledgeStatus::Superseded => "superseded",
104            KnowledgeStatus::Archived => "archived",
105        }
106    }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum KnowledgeSource {
112    User,
113    Agent,
114    Reconciler,
115    Import,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum KnowledgeLinkType {
121    RelatesTo,
122    Supersedes,
123    DerivedFrom,
124    Contradicts,
125    Duplicates,
126}
127
128impl KnowledgeLinkType {
129    pub fn as_str(&self) -> &'static str {
130        match self {
131            KnowledgeLinkType::RelatesTo => "relates_to",
132            KnowledgeLinkType::Supersedes => "supersedes",
133            KnowledgeLinkType::DerivedFrom => "derived_from",
134            KnowledgeLinkType::Contradicts => "contradicts",
135            KnowledgeLinkType::Duplicates => "duplicates",
136        }
137    }
138}
139
140/// Typed edge from the owning document to `to`.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct KnowledgeLink {
144    #[serde(rename = "type")]
145    pub link_type: KnowledgeLinkType,
146    pub to: KnowledgeDocId,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct KnowledgeDocument {
152    pub id: KnowledgeDocId,
153    pub scope: MemoryScope,
154    pub kind: KnowledgeKind,
155    pub slug: String,
156    pub title: String,
157    pub status: KnowledgeStatus,
158    pub source: KnowledgeSource,
159    #[serde(default)]
160    pub tags: Vec<String>,
161    #[serde(default)]
162    pub links: Vec<KnowledgeLink>,
163    pub revision: u32,
164    pub content_hash: String,
165    pub body: String,
166    #[serde(with = "time::serde::rfc3339")]
167    pub created_at: OffsetDateTime,
168    #[serde(with = "time::serde::rfc3339")]
169    pub updated_at: OffsetDateTime,
170}
171
172impl KnowledgeDocument {
173    pub fn summary(&self) -> KnowledgeDocSummary {
174        const PREVIEW_CHARS: usize = 160;
175        let preview = if self.body.chars().count() <= PREVIEW_CHARS {
176            self.body.clone()
177        } else {
178            let mut out = self.body.chars().take(PREVIEW_CHARS).collect::<String>();
179            out.push_str("...");
180            out
181        };
182        KnowledgeDocSummary {
183            id: self.id.clone(),
184            scope: self.scope.clone(),
185            kind: self.kind.clone(),
186            slug: self.slug.clone(),
187            title: self.title.clone(),
188            status: self.status,
189            source: self.source,
190            tags: self.tags.clone(),
191            links: self.links.clone(),
192            revision: self.revision,
193            byte_count: self.body.len() as u64,
194            preview,
195            created_at: self.created_at,
196            updated_at: self.updated_at,
197        }
198    }
199}
200
201/// Body-free view used for listing and events so corpora stay cheap to ship.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct KnowledgeDocSummary {
205    pub id: KnowledgeDocId,
206    pub scope: MemoryScope,
207    pub kind: KnowledgeKind,
208    pub slug: String,
209    pub title: String,
210    pub status: KnowledgeStatus,
211    pub source: KnowledgeSource,
212    #[serde(default)]
213    pub tags: Vec<String>,
214    #[serde(default)]
215    pub links: Vec<KnowledgeLink>,
216    pub revision: u32,
217    pub byte_count: u64,
218    pub preview: String,
219    #[serde(with = "time::serde::rfc3339")]
220    pub created_at: OffsetDateTime,
221    #[serde(with = "time::serde::rfc3339")]
222    pub updated_at: OffsetDateTime,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct KnowledgeListQuery {
228    pub scope: Option<MemoryScope>,
229    #[serde(default)]
230    pub kind: Option<KnowledgeKind>,
231    #[serde(default)]
232    pub tag: Option<String>,
233    #[serde(default)]
234    pub status: Option<KnowledgeStatus>,
235    #[serde(default)]
236    pub include_archived: bool,
237    pub limit: usize,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct KnowledgeQuery {
243    pub scope: Option<MemoryScope>,
244    pub text: String,
245    #[serde(default)]
246    pub kind: Option<KnowledgeKind>,
247    pub limit: usize,
248    #[serde(default)]
249    pub include_global: bool,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct KnowledgeCitation {
255    pub doc_id: KnowledgeDocId,
256    pub scope_id: String,
257    pub title: String,
258    pub snippet: String,
259    pub score_millis: u32,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct KnowledgeSearchResult {
265    pub document: KnowledgeDocSummary,
266    pub score: f32,
267    pub snippet: String,
268    pub citation: KnowledgeCitation,
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct KnowledgeSaveRequest {
274    pub scope: MemoryScope,
275    pub kind: KnowledgeKind,
276    pub title: String,
277    #[serde(default)]
278    pub tags: Vec<String>,
279    pub body: String,
280    pub source: KnowledgeSource,
281}
282
283/// Partial update; absent fields keep the current value. Every applied
284/// update writes a new immutable revision.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct KnowledgeUpdateRequest {
288    pub id: KnowledgeDocId,
289    #[serde(default)]
290    pub title: Option<String>,
291    #[serde(default)]
292    pub body: Option<String>,
293    #[serde(default)]
294    pub status: Option<KnowledgeStatus>,
295    #[serde(default)]
296    pub tags: Option<Vec<String>>,
297    pub source: KnowledgeSource,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct KnowledgeLinkRequest {
303    pub from: KnowledgeDocId,
304    pub to: KnowledgeDocId,
305    #[serde(rename = "type")]
306    pub link_type: KnowledgeLinkType,
307    #[serde(default)]
308    pub remove: bool,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct KnowledgeRevisionInfo {
314    pub revision: u32,
315    pub content_hash: String,
316    #[serde(with = "time::serde::rfc3339")]
317    pub created_at: OffsetDateTime,
318}
319
320#[async_trait::async_trait]
321pub trait KnowledgeStore: Send + Sync {
322    fn id(&self) -> KnowledgeStoreId;
323
324    async fn save(&self, request: KnowledgeSaveRequest) -> anyhow::Result<KnowledgeDocument>;
325    async fn get(&self, id: &KnowledgeDocId) -> anyhow::Result<Option<KnowledgeDocument>>;
326    async fn get_revision(
327        &self,
328        id: &KnowledgeDocId,
329        revision: u32,
330    ) -> anyhow::Result<Option<KnowledgeDocument>>;
331    async fn list(&self, query: KnowledgeListQuery) -> anyhow::Result<Vec<KnowledgeDocSummary>>;
332    async fn search(&self, query: KnowledgeQuery) -> anyhow::Result<Vec<KnowledgeSearchResult>>;
333    async fn update(&self, request: KnowledgeUpdateRequest) -> anyhow::Result<KnowledgeDocument>;
334    /// Soft-delete: the document leaves default lists but stays readable by id.
335    async fn archive(&self, id: &KnowledgeDocId) -> anyhow::Result<bool>;
336    async fn set_link(&self, request: KnowledgeLinkRequest) -> anyhow::Result<KnowledgeDocument>;
337    async fn revisions(&self, id: &KnowledgeDocId) -> anyhow::Result<Vec<KnowledgeRevisionInfo>>;
338}
339
340pub trait KnowledgeStoreFactory: Send + Sync + 'static {
341    fn id(&self) -> KnowledgeStoreId;
342    fn create(&self) -> Arc<dyn KnowledgeStore>;
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn knowledge_kind_serializes_as_plain_string() {
351        assert_eq!(
352            serde_json::to_value(KnowledgeKind::Decision).unwrap(),
353            serde_json::json!("decision")
354        );
355        assert_eq!(
356            serde_json::to_value(KnowledgeKind::Other("postmortem".to_string())).unwrap(),
357            serde_json::json!("postmortem")
358        );
359    }
360
361    #[test]
362    fn knowledge_kind_round_trips_known_and_custom_values() {
363        for kind in KnowledgeKind::KNOWN {
364            let parsed = KnowledgeKind::parse(kind);
365            assert_eq!(parsed.as_str(), kind);
366            assert!(!matches!(parsed, KnowledgeKind::Other(_)));
367        }
368        assert_eq!(
369            KnowledgeKind::parse("postmortem"),
370            KnowledgeKind::Other("postmortem".to_string())
371        );
372    }
373
374    #[test]
375    fn knowledge_link_serializes_type_field() {
376        let link = KnowledgeLink {
377            link_type: KnowledgeLinkType::Supersedes,
378            to: "kn-1".to_string(),
379        };
380        assert_eq!(
381            serde_json::to_value(&link).unwrap(),
382            serde_json::json!({ "type": "supersedes", "to": "kn-1" })
383        );
384    }
385
386    #[test]
387    fn document_summary_bounds_preview_and_keeps_metadata() {
388        let now = OffsetDateTime::UNIX_EPOCH;
389        let doc = KnowledgeDocument {
390            id: "kn-1".to_string(),
391            scope: MemoryScope::Project("p".to_string()),
392            kind: KnowledgeKind::Research,
393            slug: "long-doc".to_string(),
394            title: "Long doc".to_string(),
395            status: KnowledgeStatus::Active,
396            source: KnowledgeSource::Agent,
397            tags: vec!["api".to_string()],
398            links: Vec::new(),
399            revision: 3,
400            content_hash: "hash".to_string(),
401            body: "x".repeat(4000),
402            created_at: now,
403            updated_at: now,
404        };
405
406        let summary = doc.summary();
407
408        assert_eq!(summary.byte_count, 4000);
409        assert!(summary.preview.ends_with("..."));
410        assert!(summary.preview.len() < 200);
411        assert_eq!(summary.revision, 3);
412        assert_eq!(summary.tags, vec!["api".to_string()]);
413    }
414
415    #[test]
416    fn document_round_trips_json() {
417        let now = OffsetDateTime::UNIX_EPOCH;
418        let doc = KnowledgeDocument {
419            id: "kn-2".to_string(),
420            scope: MemoryScope::Global,
421            kind: KnowledgeKind::Requirement,
422            slug: "auth-req".to_string(),
423            title: "Auth requirements".to_string(),
424            status: KnowledgeStatus::Draft,
425            source: KnowledgeSource::User,
426            tags: vec!["auth".to_string()],
427            links: vec![KnowledgeLink {
428                link_type: KnowledgeLinkType::RelatesTo,
429                to: "kn-1".to_string(),
430            }],
431            revision: 1,
432            content_hash: "h".to_string(),
433            body: "Users must log in.".to_string(),
434            created_at: now,
435            updated_at: now,
436        };
437
438        let value = serde_json::to_value(&doc).unwrap();
439        let decoded: KnowledgeDocument = serde_json::from_value(value).unwrap();
440
441        assert_eq!(decoded, doc);
442    }
443}