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}