Skip to main content

mempal_runtime/
knowledge_card_backfill.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3
4use crate::core::db::{Database, DbError};
5use crate::core::types::{
6    Drawer, KnowledgeCard, KnowledgeCardEvent, KnowledgeCardFilter, KnowledgeEventType,
7    KnowledgeEvidenceLink, KnowledgeEvidenceRole,
8};
9use crate::core::utils::current_timestamp;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct KnowledgeCardBackfillReport {
13    pub ready_count: usize,
14    pub skipped_count: usize,
15    pub already_exists_count: usize,
16    pub candidates: Vec<KnowledgeCardBackfillCandidate>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct KnowledgeCardBackfillCandidate {
21    pub source_drawer_id: String,
22    pub prospective_card_id: String,
23    pub status: KnowledgeCardBackfillStatus,
24    pub reasons: Vec<String>,
25    pub statement: Option<String>,
26    pub tier: Option<String>,
27    pub knowledge_status: Option<String>,
28    pub domain: String,
29    pub field: String,
30    pub anchor_kind: String,
31    pub anchor_id: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum KnowledgeCardBackfillStatus {
37    Ready,
38    Skipped,
39    AlreadyExists,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct KnowledgeCardBackfillApplyOptions {
44    pub execute: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct KnowledgeCardBackfillApplyResult {
49    pub dry_run: bool,
50    pub ready_count: usize,
51    pub skipped_count: usize,
52    pub already_exists_count: usize,
53    pub created_count: usize,
54    pub linked_count: usize,
55    pub event_count: usize,
56    pub link_errors: Vec<KnowledgeCardBackfillLinkError>,
57    pub candidates: Vec<KnowledgeCardBackfillCandidate>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct KnowledgeCardBackfillLinkError {
62    pub card_id: String,
63    pub evidence_drawer_id: String,
64    pub role: String,
65    pub error: String,
66}
67
68pub fn build_backfill_report(
69    db: &Database,
70    filter: &KnowledgeCardFilter,
71) -> Result<KnowledgeCardBackfillReport, DbError> {
72    let drawers = db.list_knowledge_drawers_for_card_backfill(filter)?;
73    let mut candidates = Vec::with_capacity(drawers.len());
74    let mut ready_count = 0;
75    let mut skipped_count = 0;
76    let mut already_exists_count = 0;
77
78    for drawer in drawers {
79        let candidate = classify_drawer(db, drawer)?;
80        match candidate.status {
81            KnowledgeCardBackfillStatus::Ready => ready_count += 1,
82            KnowledgeCardBackfillStatus::Skipped => skipped_count += 1,
83            KnowledgeCardBackfillStatus::AlreadyExists => already_exists_count += 1,
84        }
85        candidates.push(candidate);
86    }
87
88    Ok(KnowledgeCardBackfillReport {
89        ready_count,
90        skipped_count,
91        already_exists_count,
92        candidates,
93    })
94}
95
96pub fn apply_backfill(
97    db: &Database,
98    filter: &KnowledgeCardFilter,
99    options: KnowledgeCardBackfillApplyOptions,
100) -> Result<KnowledgeCardBackfillApplyResult, DbError> {
101    let drawers = db.list_knowledge_drawers_for_card_backfill(filter)?;
102    let mut candidates = Vec::with_capacity(drawers.len());
103    let mut ready_count = 0;
104    let mut skipped_count = 0;
105    let mut already_exists_count = 0;
106    let mut created_count = 0;
107    let mut linked_count = 0;
108    let mut event_count = 0;
109    let mut link_errors = Vec::new();
110
111    for drawer in drawers {
112        let candidate = classify_drawer(db, drawer.clone())?;
113        match candidate.status {
114            KnowledgeCardBackfillStatus::Ready => {
115                ready_count += 1;
116                if options.execute {
117                    let card_id = candidate.prospective_card_id.clone();
118                    create_card_and_event(db, &drawer, &card_id)?;
119                    created_count += 1;
120                    event_count += 1;
121                    for (role, evidence_drawer_id) in evidence_refs_by_role(&drawer) {
122                        let link = KnowledgeEvidenceLink {
123                            id: prospective_link_id(&card_id, &evidence_drawer_id, &role),
124                            card_id: card_id.clone(),
125                            evidence_drawer_id: evidence_drawer_id.clone(),
126                            role: role.clone(),
127                            note: Some(format!("backfilled from {}", drawer.id)),
128                            created_at: current_timestamp(),
129                        };
130                        match db.insert_knowledge_evidence_link(&link) {
131                            Ok(()) => linked_count += 1,
132                            Err(error) => link_errors.push(KnowledgeCardBackfillLinkError {
133                                card_id: card_id.clone(),
134                                evidence_drawer_id: evidence_drawer_id.clone(),
135                                role: evidence_role_slug(&role).to_string(),
136                                error: error.to_string(),
137                            }),
138                        }
139                    }
140                }
141            }
142            KnowledgeCardBackfillStatus::Skipped => skipped_count += 1,
143            KnowledgeCardBackfillStatus::AlreadyExists => already_exists_count += 1,
144        }
145        candidates.push(candidate);
146    }
147
148    Ok(KnowledgeCardBackfillApplyResult {
149        dry_run: !options.execute,
150        ready_count,
151        skipped_count,
152        already_exists_count,
153        created_count,
154        linked_count,
155        event_count,
156        link_errors,
157        candidates,
158    })
159}
160
161pub fn prospective_card_id(source_drawer_id: &str) -> String {
162    let mut hasher = Sha256::new();
163    hasher.update(b"knowledge-card-backfill-v1");
164    hasher.update([0]);
165    hasher.update(source_drawer_id.as_bytes());
166    let digest = format!("{:x}", hasher.finalize());
167    format!("card_{}", &digest[..16])
168}
169
170fn prospective_link_id(
171    card_id: &str,
172    evidence_drawer_id: &str,
173    role: &KnowledgeEvidenceRole,
174) -> String {
175    let mut hasher = Sha256::new();
176    hasher.update(b"knowledge-card-backfill-link-v1");
177    hasher.update([0]);
178    hasher.update(card_id.as_bytes());
179    hasher.update([0]);
180    hasher.update(evidence_drawer_id.as_bytes());
181    hasher.update([0]);
182    hasher.update(evidence_role_slug(role).as_bytes());
183    let digest = format!("{:x}", hasher.finalize());
184    format!("link_{}", &digest[..16])
185}
186
187fn classify_drawer(
188    db: &Database,
189    drawer: Drawer,
190) -> Result<KnowledgeCardBackfillCandidate, DbError> {
191    let prospective_card_id = prospective_card_id(&drawer.id);
192    let reasons = skip_reasons(&drawer);
193    let status = if !reasons.is_empty() {
194        KnowledgeCardBackfillStatus::Skipped
195    } else if db.get_knowledge_card(&prospective_card_id)?.is_some() {
196        KnowledgeCardBackfillStatus::AlreadyExists
197    } else {
198        KnowledgeCardBackfillStatus::Ready
199    };
200
201    Ok(KnowledgeCardBackfillCandidate {
202        source_drawer_id: drawer.id,
203        prospective_card_id,
204        status,
205        reasons,
206        statement: drawer.statement,
207        tier: drawer
208            .tier
209            .as_ref()
210            .map(knowledge_tier_slug)
211            .map(str::to_string),
212        knowledge_status: drawer
213            .status
214            .as_ref()
215            .map(knowledge_status_slug)
216            .map(str::to_string),
217        domain: memory_domain_slug(&drawer.domain).to_string(),
218        field: drawer.field,
219        anchor_kind: anchor_kind_slug(&drawer.anchor_kind).to_string(),
220        anchor_id: drawer.anchor_id,
221    })
222}
223
224fn create_card_and_event(db: &Database, drawer: &Drawer, card_id: &str) -> Result<(), DbError> {
225    let now = current_timestamp();
226    let card = KnowledgeCard {
227        id: card_id.to_string(),
228        statement: drawer.statement.clone().unwrap_or_default(),
229        content: drawer.content.clone(),
230        tier: drawer.tier.clone().expect("ready candidate must have tier"),
231        status: drawer
232            .status
233            .clone()
234            .expect("ready candidate must have status"),
235        domain: drawer.domain.clone(),
236        field: drawer.field.clone(),
237        anchor_kind: drawer.anchor_kind.clone(),
238        anchor_id: drawer.anchor_id.clone(),
239        parent_anchor_id: drawer.parent_anchor_id.clone(),
240        scope_constraints: drawer.scope_constraints.clone(),
241        trigger_hints: drawer.trigger_hints.clone(),
242        created_at: now.clone(),
243        updated_at: now.clone(),
244    };
245    let event = KnowledgeCardEvent {
246        id: prospective_created_event_id(card_id),
247        card_id: card_id.to_string(),
248        event_type: KnowledgeEventType::Created,
249        from_status: None,
250        to_status: drawer.status.clone(),
251        reason: format!("backfilled from Stage-1 knowledge drawer {}", drawer.id),
252        actor: Some("mempal".to_string()),
253        metadata: Some(serde_json::json!({
254            "source_drawer_id": drawer.id,
255            "source_file": drawer.source_file,
256        })),
257        created_at: now,
258    };
259
260    db.conn().execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
261    let result = db
262        .insert_knowledge_card(&card)
263        .and_then(|()| db.append_knowledge_event(&event));
264    match result {
265        Ok(()) => {
266            db.conn().execute_batch("COMMIT")?;
267            Ok(())
268        }
269        Err(error) => {
270            let _ = db.conn().execute_batch("ROLLBACK");
271            Err(error)
272        }
273    }
274}
275
276fn prospective_created_event_id(card_id: &str) -> String {
277    let mut hasher = Sha256::new();
278    hasher.update(b"knowledge-card-backfill-created-event-v1");
279    hasher.update([0]);
280    hasher.update(card_id.as_bytes());
281    let digest = format!("{:x}", hasher.finalize());
282    format!("event_{}", &digest[..16])
283}
284
285fn evidence_refs_by_role(drawer: &Drawer) -> Vec<(KnowledgeEvidenceRole, String)> {
286    let mut refs = Vec::new();
287    refs.extend(
288        drawer
289            .supporting_refs
290            .iter()
291            .cloned()
292            .map(|id| (KnowledgeEvidenceRole::Supporting, id)),
293    );
294    refs.extend(
295        drawer
296            .verification_refs
297            .iter()
298            .cloned()
299            .map(|id| (KnowledgeEvidenceRole::Verification, id)),
300    );
301    refs.extend(
302        drawer
303            .counterexample_refs
304            .iter()
305            .cloned()
306            .map(|id| (KnowledgeEvidenceRole::Counterexample, id)),
307    );
308    refs.extend(
309        drawer
310            .teaching_refs
311            .iter()
312            .cloned()
313            .map(|id| (KnowledgeEvidenceRole::Teaching, id)),
314    );
315    refs
316}
317
318fn skip_reasons(drawer: &Drawer) -> Vec<String> {
319    let mut reasons = Vec::new();
320    if drawer
321        .statement
322        .as_deref()
323        .map(str::trim)
324        .filter(|value| !value.is_empty())
325        .is_none()
326    {
327        reasons.push("missing statement".to_string());
328    }
329    if drawer.tier.is_none() {
330        reasons.push("missing tier".to_string());
331    }
332    if drawer.status.is_none() {
333        reasons.push("missing status".to_string());
334    }
335    if drawer.field.trim().is_empty() {
336        reasons.push("missing field".to_string());
337    }
338    if drawer.anchor_id.trim().is_empty() {
339        reasons.push("missing anchor_id".to_string());
340    }
341    reasons
342}
343
344fn evidence_role_slug(value: &KnowledgeEvidenceRole) -> &'static str {
345    match value {
346        KnowledgeEvidenceRole::Supporting => "supporting",
347        KnowledgeEvidenceRole::Verification => "verification",
348        KnowledgeEvidenceRole::Counterexample => "counterexample",
349        KnowledgeEvidenceRole::Teaching => "teaching",
350    }
351}
352
353fn memory_domain_slug(value: &crate::core::types::MemoryDomain) -> &'static str {
354    match value {
355        crate::core::types::MemoryDomain::Project => "project",
356        crate::core::types::MemoryDomain::Agent => "agent",
357        crate::core::types::MemoryDomain::Skill => "skill",
358        crate::core::types::MemoryDomain::Global => "global",
359    }
360}
361
362fn knowledge_tier_slug(value: &crate::core::types::KnowledgeTier) -> &'static str {
363    match value {
364        crate::core::types::KnowledgeTier::Qi => "qi",
365        crate::core::types::KnowledgeTier::Shu => "shu",
366        crate::core::types::KnowledgeTier::DaoRen => "dao_ren",
367        crate::core::types::KnowledgeTier::DaoTian => "dao_tian",
368    }
369}
370
371fn knowledge_status_slug(value: &crate::core::types::KnowledgeStatus) -> &'static str {
372    match value {
373        crate::core::types::KnowledgeStatus::Candidate => "candidate",
374        crate::core::types::KnowledgeStatus::Promoted => "promoted",
375        crate::core::types::KnowledgeStatus::Canonical => "canonical",
376        crate::core::types::KnowledgeStatus::Demoted => "demoted",
377        crate::core::types::KnowledgeStatus::Retired => "retired",
378    }
379}
380
381fn anchor_kind_slug(value: &crate::core::types::AnchorKind) -> &'static str {
382    match value {
383        crate::core::types::AnchorKind::Global => "global",
384        crate::core::types::AnchorKind::Repo => "repo",
385        crate::core::types::AnchorKind::Worktree => "worktree",
386    }
387}