claw10_memory/
admission.rs1use claw10_domain::{Memory, MemoryId, MemoryStatus};
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum AdmissionDecision {
24 Continue,
26 Activate,
28 Reject { reason: String },
30}
31
32#[derive(Debug, Clone)]
34pub struct AdmissionConfig {
35 pub min_confidence: f64,
37 pub injection_keywords: Vec<String>,
39 pub allow_duplicates: bool,
41}
42
43impl Default for AdmissionConfig {
44 fn default() -> Self {
45 Self {
46 min_confidence: 0.6,
47 injection_keywords: vec![
48 "ignore previous".into(),
49 "disregard".into(),
50 "forget all".into(),
51 "system prompt".into(),
52 "jailbreak".into(),
53 "</system>".into(),
54 "<|im_start|>".into(),
55 ],
56 allow_duplicates: false,
57 }
58 }
59}
60
61#[derive(Debug)]
63pub enum AdmissionResult {
64 Activated,
66 Rejected { reason: String },
68}
69
70pub struct AdmissionPipeline {
72 config: AdmissionConfig,
73}
74
75impl AdmissionPipeline {
76 #[must_use]
77 pub fn new(config: AdmissionConfig) -> Self {
78 Self { config }
79 }
80
81 #[must_use]
82 pub fn with_defaults() -> Self {
83 Self::new(AdmissionConfig::default())
84 }
85
86 pub fn evaluate(&self, candidate: &Memory, existing: &[Memory]) -> AdmissionResult {
89 if candidate.status != MemoryStatus::Candidate
91 && candidate.status != MemoryStatus::Scanning
92 {
93 return AdmissionResult::Rejected {
94 reason: format!(
95 "status tidak valid untuk admission: {:?}",
96 candidate.status
97 ),
98 };
99 }
100
101 if let AdmissionDecision::Reject { reason } = self.scan_injection(&candidate.content) {
103 return AdmissionResult::Rejected { reason };
104 }
105
106 if let AdmissionDecision::Reject { reason } =
108 self.check_confidence(candidate.confidence)
109 {
110 return AdmissionResult::Rejected { reason };
111 }
112
113 if let AdmissionDecision::Reject { reason } =
115 self.check_duplicate(candidate, existing)
116 {
117 return AdmissionResult::Rejected { reason };
118 }
119
120 if let AdmissionDecision::Reject { reason } = self.check_source(candidate) {
122 return AdmissionResult::Rejected { reason };
123 }
124
125 if let AdmissionDecision::Reject { reason } = self.check_classification(candidate) {
127 return AdmissionResult::Rejected { reason };
128 }
129
130 AdmissionResult::Activated
131 }
132
133 fn scan_injection(&self, content: &str) -> AdmissionDecision {
136 let lower = content.to_lowercase();
137 for keyword in &self.config.injection_keywords {
138 if lower.contains(keyword.as_str()) {
139 return AdmissionDecision::Reject {
140 reason: format!("injection pattern terdeteksi: '{keyword}'"),
141 };
142 }
143 }
144 AdmissionDecision::Continue
145 }
146
147 fn check_confidence(&self, confidence: f64) -> AdmissionDecision {
148 if confidence < self.config.min_confidence {
149 AdmissionDecision::Reject {
150 reason: format!(
151 "confidence {:.2} di bawah minimum {:.2}",
152 confidence, self.config.min_confidence
153 ),
154 }
155 } else {
156 AdmissionDecision::Continue
157 }
158 }
159
160 fn check_duplicate(&self, candidate: &Memory, existing: &[Memory]) -> AdmissionDecision {
161 if self.config.allow_duplicates {
162 return AdmissionDecision::Continue;
163 }
164
165 for mem in existing {
166 if mem.id == candidate.id {
167 continue; }
169 if mem.scope == candidate.scope
170 && mem.content.trim().to_lowercase()
171 == candidate.content.trim().to_lowercase()
172 && mem.status == MemoryStatus::Active
173 {
174 return AdmissionDecision::Reject {
175 reason: format!(
176 "duplikat dengan memory aktif {} dalam scope '{}'",
177 mem.id.0, candidate.scope
178 ),
179 };
180 }
181 }
182
183 AdmissionDecision::Continue
184 }
185
186 fn check_source(&self, candidate: &Memory) -> AdmissionDecision {
187 if candidate.source.agent_id.0.is_nil() {
189 return AdmissionDecision::Reject {
190 reason: "source agent_id tidak valid (nil UUID)".into(),
191 };
192 }
193 AdmissionDecision::Continue
194 }
195
196 fn check_classification(&self, candidate: &Memory) -> AdmissionDecision {
197 if candidate.classification.trim().is_empty() {
198 return AdmissionDecision::Reject {
199 reason: "classification tidak boleh kosong".into(),
200 };
201 }
202 AdmissionDecision::Continue
203 }
204}
205
206pub fn rejection_reason_for(id: &MemoryId, reason: &str) -> String {
208 format!("memory {} ditolak: {}", id.0, reason)
209}
210
211#[cfg(test)]
212#[path = "admission_test.rs"]
213mod tests;
214