1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Serialize;
7
8use crate::core::{
9 anchor,
10 db::Database,
11 types::{
12 BootstrapIdentityParts, Drawer, KnowledgeStatus, KnowledgeTier, MemoryDomain, MemoryKind,
13 SourceType, TriggerHints,
14 },
15 utils::{build_bootstrap_drawer_id_from_parts, current_timestamp, knowledge_source_file},
16};
17use crate::ingest::normalize::CURRENT_NORMALIZE_VERSION;
18
19#[derive(Debug, Clone)]
20pub struct DistillRequest {
21 pub statement: String,
22 pub content: String,
23 pub tier: String,
24 pub supporting_refs: Vec<String>,
25 pub wing: String,
26 pub room: String,
27 pub domain: String,
28 pub field: String,
29 pub cwd: Option<PathBuf>,
30 pub scope_constraints: Option<String>,
31 pub counterexample_refs: Vec<String>,
32 pub teaching_refs: Vec<String>,
33 pub trigger_hints: Option<TriggerHints>,
34 pub importance: i32,
35 pub dry_run: bool,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct DistillOutcome {
40 pub drawer_id: String,
41 pub created: bool,
42 pub dry_run: bool,
43}
44
45#[derive(Debug, Clone)]
46pub enum DistillPlan {
47 Done(DistillOutcome),
48 Create(Box<PreparedDistill>),
49}
50
51#[derive(Debug, Clone)]
52pub struct PreparedDistill {
53 pub drawer_id: String,
54 pub content: String,
55 drawer: Drawer,
56 supporting_refs: Vec<String>,
57 counterexample_refs: Vec<String>,
58 teaching_refs: Vec<String>,
59}
60
61pub fn prepare_distill(db: &Database, request: DistillRequest) -> Result<DistillPlan> {
62 if !(0..=5).contains(&request.importance) {
63 bail!("importance must be between 0 and 5");
64 }
65 let statement = trim_required(&request.statement, "statement")?;
66 let content = trim_required(&request.content, "content")?;
67 let wing = trim_required(&request.wing, "wing")?;
68 let room = trim_required(&request.room, "room")?;
69 let field = trim_required(&request.field, "field")?;
70 let domain = parse_domain(&request.domain)?;
71 let tier = parse_distill_tier(&request.tier)?;
72 let memory_kind = MemoryKind::Knowledge;
73 let status = KnowledgeStatus::Candidate;
74 let verification_refs: &[String] = &[];
75
76 let supporting_refs = normalized_nonempty_strings(&request.supporting_refs);
77 let counterexample_refs = normalized_nonempty_strings(&request.counterexample_refs);
78 let teaching_refs = normalized_nonempty_strings(&request.teaching_refs);
79 validate_distill_refs(db, "supporting_refs", &supporting_refs)?;
80 validate_distill_refs(db, "counterexample_refs", &counterexample_refs)?;
81 validate_distill_refs(db, "teaching_refs", &teaching_refs)?;
82
83 let scope_constraints = request
84 .scope_constraints
85 .as_deref()
86 .map(str::trim)
87 .filter(|value| !value.is_empty())
88 .map(ToOwned::to_owned);
89 let trigger_hints = request.trigger_hints.and_then(normalize_trigger_hints);
90 let anchor = distill_anchor(&domain, request.cwd.as_deref())?;
91 anchor::validate_anchor_domain(&domain, &anchor.anchor_kind)
92 .map_err(|message| anyhow::anyhow!(message.to_string()))?;
93
94 let drawer_id = build_bootstrap_drawer_id_from_parts(
95 &wing,
96 Some(&room),
97 &content,
98 BootstrapIdentityParts {
99 memory_kind: &memory_kind,
100 domain: &domain,
101 field: &field,
102 anchor_kind: &anchor.anchor_kind,
103 anchor_id: &anchor.anchor_id,
104 parent_anchor_id: anchor.parent_anchor_id.as_deref(),
105 provenance: None,
106 statement: Some(&statement),
107 tier: Some(&tier),
108 status: Some(&status),
109 supporting_refs: &supporting_refs,
110 counterexample_refs: &counterexample_refs,
111 teaching_refs: &teaching_refs,
112 verification_refs,
113 scope_constraints: scope_constraints.as_deref(),
114 trigger_hints: trigger_hints.as_ref(),
115 },
116 );
117
118 if request.dry_run {
119 return Ok(DistillPlan::Done(DistillOutcome {
120 drawer_id,
121 created: false,
122 dry_run: true,
123 }));
124 }
125
126 if db
127 .drawer_exists(&drawer_id)
128 .context("failed to check existing distilled drawer")?
129 {
130 return Ok(DistillPlan::Done(DistillOutcome {
131 drawer_id,
132 created: false,
133 dry_run: false,
134 }));
135 }
136
137 let drawer = Drawer {
138 id: drawer_id.clone(),
139 content: content.clone(),
140 wing,
141 room: Some(room),
142 source_file: Some(knowledge_source_file(&domain, &field, &tier, &statement)),
143 source_type: SourceType::Manual,
144 added_at: current_timestamp(),
145 chunk_index: Some(0),
146 normalize_version: CURRENT_NORMALIZE_VERSION,
147 importance: request.importance,
148 memory_kind: MemoryKind::Knowledge,
149 domain,
150 field,
151 anchor_kind: anchor.anchor_kind,
152 anchor_id: anchor.anchor_id,
153 parent_anchor_id: anchor.parent_anchor_id,
154 provenance: None,
155 statement: Some(statement),
156 tier: Some(tier),
157 status: Some(status),
158 supporting_refs: supporting_refs.clone(),
159 counterexample_refs: counterexample_refs.clone(),
160 teaching_refs: teaching_refs.clone(),
161 verification_refs: Vec::new(),
162 scope_constraints,
163 trigger_hints,
164 };
165
166 Ok(DistillPlan::Create(Box::new(PreparedDistill {
167 drawer_id,
168 content,
169 drawer,
170 supporting_refs,
171 counterexample_refs,
172 teaching_refs,
173 })))
174}
175
176pub fn commit_distill(
177 db: &Database,
178 prepared: PreparedDistill,
179 vector: &[f32],
180) -> Result<DistillOutcome> {
181 db.insert_drawer(&prepared.drawer)
182 .context("failed to insert distilled knowledge drawer")?;
183 db.insert_vector(&prepared.drawer_id, vector)
184 .context("failed to insert distilled knowledge vector")?;
185 append_audit_entry(
186 db,
187 "knowledge_distill",
188 &serde_json::json!({
189 "drawer_id": prepared.drawer_id,
190 "statement": prepared.drawer.statement,
191 "tier": prepared.drawer.tier.as_ref().map(tier_slug),
192 "status": prepared.drawer.status.as_ref().map(status_slug),
193 "supporting_refs": prepared.supporting_refs,
194 "counterexample_refs": prepared.counterexample_refs,
195 "teaching_refs": prepared.teaching_refs,
196 }),
197 )
198 .context("failed to append audit log")?;
199
200 Ok(DistillOutcome {
201 drawer_id: prepared.drawer_id,
202 created: true,
203 dry_run: false,
204 })
205}
206
207fn trim_required(value: &str, field: &str) -> Result<String> {
208 let trimmed = value.trim();
209 if trimmed.is_empty() {
210 bail!("{field} must not be empty");
211 }
212 Ok(trimmed.to_string())
213}
214
215fn normalized_nonempty_strings(values: &[String]) -> Vec<String> {
216 values
217 .iter()
218 .filter_map(|value| {
219 let trimmed = value.trim();
220 (!trimmed.is_empty()).then(|| trimmed.to_string())
221 })
222 .collect()
223}
224
225fn normalize_trigger_hints(hints: TriggerHints) -> Option<TriggerHints> {
226 let intent_tags = normalized_nonempty_strings(&hints.intent_tags);
227 let workflow_bias = normalized_nonempty_strings(&hints.workflow_bias);
228 let tool_needs = normalized_nonempty_strings(&hints.tool_needs);
229 if intent_tags.is_empty() && workflow_bias.is_empty() && tool_needs.is_empty() {
230 return None;
231 }
232 Some(TriggerHints {
233 intent_tags,
234 workflow_bias,
235 tool_needs,
236 })
237}
238
239fn parse_domain(value: &str) -> Result<MemoryDomain> {
240 match value.trim() {
241 "project" => Ok(MemoryDomain::Project),
242 "agent" => Ok(MemoryDomain::Agent),
243 "skill" => Ok(MemoryDomain::Skill),
244 "global" => Ok(MemoryDomain::Global),
245 other => bail!("unsupported domain: {other}"),
246 }
247}
248
249fn parse_distill_tier(value: &str) -> Result<KnowledgeTier> {
250 match value.trim() {
251 "dao_ren" => Ok(KnowledgeTier::DaoRen),
252 "qi" => Ok(KnowledgeTier::Qi),
253 "dao_tian" | "shu" => bail!("distill only allows candidate dao_ren or qi"),
254 other => bail!("unsupported knowledge tier: {other}"),
255 }
256}
257
258fn distill_anchor(domain: &MemoryDomain, cwd: Option<&Path>) -> Result<anchor::DerivedAnchor> {
259 if matches!(domain, MemoryDomain::Global) {
260 return Ok(anchor::DerivedAnchor {
261 anchor_kind: crate::core::types::AnchorKind::Global,
262 anchor_id: "global://default".to_string(),
263 parent_anchor_id: None,
264 });
265 }
266 let cwd = match cwd {
267 Some(path) => path.to_path_buf(),
268 None => std::env::current_dir().context("failed to read current directory")?,
269 };
270 anchor::derive_anchor_from_cwd(Some(&cwd)).context("failed to derive distill anchor")
271}
272
273fn validate_distill_refs(db: &Database, field: &str, refs: &[String]) -> Result<()> {
274 if field == "supporting_refs" && refs.is_empty() {
275 bail!("supporting_refs must not be empty");
276 }
277 for drawer_id in refs {
278 if !drawer_id.starts_with("drawer_") {
279 bail!("{field} must contain drawer ids");
280 }
281 let drawer = db
282 .get_drawer(drawer_id)
283 .with_context(|| format!("failed to load ref drawer {drawer_id}"))?
284 .with_context(|| format!("ref drawer not found: {drawer_id}"))?;
285 if drawer.memory_kind != MemoryKind::Evidence {
286 bail!("{field} must point to evidence drawers");
287 }
288 }
289 Ok(())
290}
291
292fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
293 let audit_path = db
294 .path()
295 .parent()
296 .map(|parent| parent.join("audit.jsonl"))
297 .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
298 let mut file = OpenOptions::new()
299 .create(true)
300 .append(true)
301 .open(&audit_path)
302 .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
303 let entry = serde_json::json!({
304 "ts": current_timestamp(),
305 "command": command,
306 "details": details,
307 });
308 writeln!(
309 file,
310 "{}",
311 serde_json::to_string(&entry).context("failed to serialize audit entry")?
312 )
313 .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
314 Ok(())
315}
316
317fn tier_slug(value: &KnowledgeTier) -> &'static str {
318 match value {
319 KnowledgeTier::Qi => "qi",
320 KnowledgeTier::Shu => "shu",
321 KnowledgeTier::DaoRen => "dao_ren",
322 KnowledgeTier::DaoTian => "dao_tian",
323 }
324}
325
326fn status_slug(value: &KnowledgeStatus) -> &'static str {
327 match value {
328 KnowledgeStatus::Candidate => "candidate",
329 KnowledgeStatus::Promoted => "promoted",
330 KnowledgeStatus::Canonical => "canonical",
331 KnowledgeStatus::Demoted => "demoted",
332 KnowledgeStatus::Retired => "retired",
333 }
334}