entelix_core/ir/
system.rs1use std::sync::Arc;
29
30use serde::{Deserialize, Serialize};
31
32use crate::ir::cache::CacheControl;
33
34#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
37pub struct SystemBlock {
38 pub text: String,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub cache_control: Option<CacheControl>,
45}
46
47impl SystemBlock {
48 #[must_use]
50 pub fn text(text: impl Into<String>) -> Self {
51 Self {
52 text: text.into(),
53 cache_control: None,
54 }
55 }
56
57 #[must_use]
59 pub fn cached(text: impl Into<String>, cache: CacheControl) -> Self {
60 Self {
61 text: text.into(),
62 cache_control: Some(cache),
63 }
64 }
65
66 #[must_use]
68 pub fn as_str(&self) -> &str {
69 &self.text
70 }
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
83#[serde(transparent)]
84pub struct SystemPrompt {
85 blocks: Arc<[SystemBlock]>,
86}
87
88impl Default for SystemPrompt {
89 fn default() -> Self {
90 Self::empty()
91 }
92}
93
94impl SystemPrompt {
95 #[must_use]
98 pub fn empty() -> Self {
99 Self {
100 blocks: Arc::from([]),
101 }
102 }
103
104 #[must_use]
106 pub fn text(text: impl Into<String>) -> Self {
107 Self {
108 blocks: Arc::from([SystemBlock::text(text)]),
109 }
110 }
111
112 #[must_use]
114 pub fn cached(text: impl Into<String>, cache: CacheControl) -> Self {
115 Self {
116 blocks: Arc::from([SystemBlock::cached(text, cache)]),
117 }
118 }
119
120 #[must_use]
122 pub fn is_empty(&self) -> bool {
123 self.blocks.is_empty()
124 }
125
126 #[must_use]
128 pub fn len(&self) -> usize {
129 self.blocks.len()
130 }
131
132 #[must_use]
134 pub fn blocks(&self) -> &[SystemBlock] {
135 &self.blocks
136 }
137
138 #[must_use]
142 pub fn any_cached(&self) -> bool {
143 self.blocks.iter().any(|b| b.cache_control.is_some())
144 }
145
146 #[must_use]
158 pub fn map_blocks<F>(&self, mut f: F) -> Self
159 where
160 F: FnMut(&mut SystemBlock),
161 {
162 let blocks: Vec<SystemBlock> = self
163 .blocks
164 .iter()
165 .map(|b| {
166 let mut clone = b.clone();
167 f(&mut clone);
168 clone
169 })
170 .collect();
171 Self {
172 blocks: Arc::from(blocks),
173 }
174 }
175
176 #[must_use]
180 pub fn concat_text(&self) -> String {
181 self.blocks
182 .iter()
183 .map(|b| b.text.as_str())
184 .collect::<Vec<_>>()
185 .join("\n\n")
186 }
187}
188
189impl From<&str> for SystemPrompt {
190 fn from(s: &str) -> Self {
191 Self::text(s)
192 }
193}
194
195impl From<String> for SystemPrompt {
196 fn from(s: String) -> Self {
197 Self::text(s)
198 }
199}
200
201impl From<SystemBlock> for SystemPrompt {
202 fn from(block: SystemBlock) -> Self {
203 Self {
204 blocks: Arc::from([block]),
205 }
206 }
207}
208
209impl From<Vec<SystemBlock>> for SystemPrompt {
210 fn from(blocks: Vec<SystemBlock>) -> Self {
211 Self {
212 blocks: Arc::from(blocks),
213 }
214 }
215}
216
217#[cfg(test)]
218#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn empty_prompt_has_no_blocks_and_renders_to_empty_string() {
224 let p = SystemPrompt::empty();
225 assert!(p.is_empty());
226 assert_eq!(p.len(), 0);
227 assert_eq!(p.concat_text(), "");
228 assert!(!p.any_cached());
229 }
230
231 #[test]
232 fn from_str_produces_single_uncached_block() {
233 let p: SystemPrompt = "Be terse.".into();
234 assert_eq!(p.len(), 1);
235 assert_eq!(p.blocks()[0].text, "Be terse.");
236 assert!(p.blocks()[0].cache_control.is_none());
237 }
238
239 #[test]
240 fn cached_constructor_attaches_cache_control() {
241 let p = SystemPrompt::cached("stable instructions", CacheControl::one_hour());
242 assert!(p.any_cached());
243 assert_eq!(
244 p.blocks()[0].cache_control.unwrap().ttl,
245 crate::ir::cache::CacheTtl::OneHour
246 );
247 }
248
249 #[test]
250 fn concat_text_joins_blocks_with_double_newline() {
251 let p = SystemPrompt::from(vec![
252 SystemBlock::text("first"),
253 SystemBlock::text("second"),
254 ]);
255 assert_eq!(p.concat_text(), "first\n\nsecond");
256 }
257
258 #[test]
259 fn map_blocks_lets_redactor_rebuild_with_transformed_text() {
260 let p = SystemPrompt::from(vec![SystemBlock::text("alpha"), SystemBlock::text("beta")]);
261 let upper = p.map_blocks(|block| {
262 block.text = block.text.to_uppercase();
263 });
264 assert_eq!(upper.concat_text(), "ALPHA\n\nBETA");
265 assert_eq!(p.concat_text(), "alpha\n\nbeta");
268 }
269
270 #[test]
271 fn clone_is_atomic_refcount_bump() {
272 let p = SystemPrompt::cached("long stable preamble".repeat(100), CacheControl::one_hour());
276 let cloned = p.clone();
277 assert!(Arc::ptr_eq(&p.blocks, &cloned.blocks));
278 }
279
280 #[test]
281 fn round_trips_via_serde_when_cached() {
282 let p = SystemPrompt::cached("x", CacheControl::five_minutes());
283 let json = serde_json::to_string(&p).unwrap();
284 let back: SystemPrompt = serde_json::from_str(&json).unwrap();
285 assert_eq!(p, back);
286 }
287}