1use std::sync::Arc;
10use crate::prompt::{PromptSection, SectionCache, CacheKey};
11use crate::prompt::{ContextInjector, UserContext, SystemContext};
12
13pub const CACHE_BOUNDARY: &str = "\n<!-- CACHE_BOUNDARY -->\n";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum PromptProfile {
22 Default,
24 Safe,
26 Fast,
28 Review,
30}
31
32impl PromptProfile {
33 pub fn as_str(&self) -> &'static str {
34 match self {
35 Self::Default => "default",
36 Self::Safe => "safe",
37 Self::Fast => "fast",
38 Self::Review => "review",
39 }
40 }
41
42 pub fn from_str(s: &str) -> Self {
43 match s {
44 "safe" => Self::Safe,
45 "fast" => Self::Fast,
46 "review" => Self::Review,
47 _ => Self::Default,
48 }
49 }
50}
51
52impl std::fmt::Display for PromptProfile {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 write!(f, "{}", self.as_str())
55 }
56}
57
58impl Default for PromptProfile {
59 fn default() -> Self {
60 Self::Default
61 }
62}
63
64pub struct PromptOrchestrator {
66 cache: Arc<SectionCache>,
68 context_injector: ContextInjector,
70 profile: PromptProfile,
72 sections: Vec<PromptSection>,
74 include_boundary: bool,
76 inject_context: bool,
78}
79
80impl PromptOrchestrator {
81 pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
83 Self {
84 cache: Arc::new(SectionCache::new()),
85 context_injector: ContextInjector::new(working_dir.into()),
86 profile: PromptProfile::Default,
87 sections: Vec::new(),
88 include_boundary: true,
89 inject_context: true,
90 }
91 }
92
93 pub fn with_cache(mut self, cache: Arc<SectionCache>) -> Self {
95 self.cache = cache;
96 self
97 }
98
99 pub fn with_profile(mut self, profile: PromptProfile) -> Self {
101 self.profile = profile;
102 self
103 }
104
105 pub fn with_boundary(mut self, include: bool) -> Self {
107 self.include_boundary = include;
108 self
109 }
110
111 pub fn with_context_injection(mut self, inject: bool) -> Self {
113 self.inject_context = inject;
114 self
115 }
116
117 pub fn add_section(&mut self, section: PromptSection) -> &mut Self {
119 self.sections.push(section);
120 self
121 }
122
123 pub fn add_sections(&mut self, sections: Vec<PromptSection>) -> &mut Self {
125 self.sections.extend(sections);
126 self
127 }
128
129 pub fn clear_sections(&mut self) -> &mut Self {
131 self.sections.clear();
132 self
133 }
134
135 pub fn invalidate_cache(&mut self) {
137 self.cache.clear();
138 self.context_injector.invalidate();
139 }
140
141 fn render_section(&self, section: &PromptSection) -> String {
143 if section.cacheable {
144 let key = CacheKey::new(§ion.name, self.profile.as_str());
145 self.cache.get_or_compute(&key, || section.compute_content())
146 } else {
147 section.compute_content()
148 }
149 }
150
151 pub fn assemble(&mut self) -> AssembledPrompt {
153 let mut cached_parts = Vec::new();
154 let mut dynamic_parts = Vec::new();
155 let mut cached_tokens = 0;
156 let mut dynamic_tokens = 0;
157
158 let mut sections = self.sections.clone();
160 sections.sort_by_key(|s| s.order);
161
162 for section in §ions {
164 let content = self.render_section(section);
165
166 if section.cacheable {
167 cached_parts.push((section.name.clone(), content.clone()));
168 cached_tokens += self.estimate_tokens(&content);
169 } else {
170 dynamic_parts.push((section.name.clone(), content.clone()));
171 dynamic_tokens += self.estimate_tokens(&content);
172 }
173 }
174
175 let context_section = if self.inject_context {
177 let ctx = self.context_injector.render_full_context();
178 dynamic_tokens += self.estimate_tokens(&ctx);
179 Some(ctx)
180 } else {
181 None
182 };
183
184 let mut final_parts = Vec::new();
186
187 for (name, content) in &cached_parts {
189 if !content.is_empty() {
190 final_parts.push(format!("[{}]\n{}", name, content));
191 }
192 }
193
194 if self.include_boundary && !cached_parts.is_empty() && (!dynamic_parts.is_empty() || context_section.is_some()) {
196 final_parts.push(CACHE_BOUNDARY.to_string());
197 }
198
199 for (name, content) in &dynamic_parts {
201 if !content.is_empty() {
202 final_parts.push(format!("[{}]\n{}", name, content));
203 }
204 }
205
206 if let Some(ctx) = context_section {
208 final_parts.push(ctx);
209 }
210
211 let full_prompt = final_parts.join("\n\n");
212
213 let stats = self.cache.stats();
215
216 AssembledPrompt {
217 prompt: full_prompt,
218 cached_sections: cached_parts.len(),
219 dynamic_sections: dynamic_parts.len(),
220 cached_tokens,
221 dynamic_tokens,
222 total_tokens: cached_tokens + dynamic_tokens,
223 cache_hit_rate: stats.hit_rate(),
224 profile: self.profile,
225 }
226 }
227
228 pub fn assemble_for_profile(&mut self, profile: PromptProfile) -> AssembledPrompt {
230 self.profile = profile;
231 self.assemble()
232 }
233
234 fn estimate_tokens(&self, content: &str) -> usize {
236 crate::prompt::cache::estimate_tokens(content)
237 }
238
239 pub fn cache_stats(&self) -> crate::prompt::cache::CacheStats {
241 self.cache.stats()
242 }
243
244 pub fn get_user_context(&mut self) -> &UserContext {
246 self.context_injector.get_user_context()
247 }
248
249 pub fn get_system_context(&mut self) -> &SystemContext {
251 self.context_injector.get_system_context()
252 }
253}
254
255#[derive(Debug, Clone)]
257pub struct AssembledPrompt {
258 pub prompt: String,
260 pub cached_sections: usize,
262 pub dynamic_sections: usize,
264 pub cached_tokens: usize,
266 pub dynamic_tokens: usize,
268 pub total_tokens: usize,
270 pub cache_hit_rate: f64,
272 pub profile: PromptProfile,
274}
275
276impl AssembledPrompt {
277 pub fn is_empty(&self) -> bool {
279 self.prompt.is_empty()
280 }
281
282 pub fn len(&self) -> usize {
284 self.prompt.len()
285 }
286
287 pub fn cache_efficiency(&self) -> f64 {
289 if self.total_tokens == 0 {
290 0.0
291 } else {
292 (self.cached_tokens as f64 / self.total_tokens as f64) * 100.0
293 }
294 }
295
296 pub fn split_at_boundary(&self) -> (Option<&str>, Option<&str>) {
298 if let Some(idx) = self.prompt.find(CACHE_BOUNDARY) {
299 let cached = &self.prompt[..idx];
300 let dynamic = &self.prompt[idx + CACHE_BOUNDARY.len()..];
301 (
302 if cached.is_empty() { None } else { Some(cached) },
303 if dynamic.is_empty() { None } else { Some(dynamic) },
304 )
305 } else {
306 if self.prompt.is_empty() {
307 (None, None)
308 } else {
309 (Some(&self.prompt), None)
310 }
311 }
312 }
313}
314
315pub struct PromptBuilder {
317 working_dir: std::path::PathBuf,
318 profile: PromptProfile,
319 sections: Vec<PromptSection>,
320 include_boundary: bool,
321 inject_context: bool,
322}
323
324impl PromptBuilder {
325 pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
326 Self {
327 working_dir: working_dir.into(),
328 profile: PromptProfile::Default,
329 sections: Vec::new(),
330 include_boundary: true,
331 inject_context: true,
332 }
333 }
334
335 pub fn profile(mut self, profile: PromptProfile) -> Self {
336 self.profile = profile;
337 self
338 }
339
340 pub fn add_section(mut self, section: PromptSection) -> Self {
341 self.sections.push(section);
342 self
343 }
344
345 pub fn add_static(self, name: impl Into<String>, content: &'static str) -> Self {
346 self.add_section(PromptSection::static_section(name, content))
347 }
348
349 pub fn add_dynamic<F>(self, name: impl Into<String>, compute: F) -> Self
350 where
351 F: Fn() -> String + Send + Sync + 'static,
352 {
353 self.add_section(PromptSection::dynamic_section(name, compute))
354 }
355
356 pub fn no_boundary(mut self) -> Self {
357 self.include_boundary = false;
358 self
359 }
360
361 pub fn no_context(mut self) -> Self {
362 self.inject_context = false;
363 self
364 }
365
366 pub fn build(self) -> PromptOrchestrator {
367 let mut orchestrator = PromptOrchestrator::new(self.working_dir)
368 .with_profile(self.profile)
369 .with_boundary(self.include_boundary)
370 .with_context_injection(self.inject_context);
371 orchestrator.add_sections(self.sections);
372 orchestrator
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_assemble_simple() {
382 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
383 orchestrator.add_section(PromptSection::static_section("identity", "You are an AI assistant."));
384
385 let assembled = orchestrator.assemble();
386 assert!(!assembled.prompt.is_empty());
387 assert!(assembled.prompt.contains("identity"));
388 assert!(assembled.cached_sections >= 1);
389 }
390
391 #[test]
392 fn test_assemble_with_dynamic() {
393 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
394 orchestrator.add_section(PromptSection::static_section("identity", "You are an AI."));
395 orchestrator.add_section(PromptSection::dynamic_section("date", || {
396 format!("Current date: {}", chrono::Local::now().format("%Y-%m-%d"))
397 }));
398
399 let assembled = orchestrator.assemble();
400 assert!(assembled.dynamic_sections >= 1);
401 assert!(assembled.cached_sections >= 1);
402 }
403
404 #[test]
405 fn test_cache_boundary() {
406 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
407 .with_boundary(true)
408 .with_context_injection(false);
409
410 orchestrator.add_section(PromptSection::static_section("cached", "cached content"));
411 orchestrator.add_section(PromptSection::dynamic_section("dynamic", || "dynamic content".to_string()));
412
413 let assembled = orchestrator.assemble();
414 assert!(assembled.prompt.contains(CACHE_BOUNDARY));
415
416 let (cached, dynamic) = assembled.split_at_boundary();
417 assert!(cached.is_some());
418 assert!(dynamic.is_some());
419 }
420
421 #[test]
422 fn test_profile() {
423 let orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
424 .with_profile(PromptProfile::Fast);
425
426 assert_eq!(orchestrator.profile, PromptProfile::Fast);
427 }
428
429 #[test]
430 fn test_builder() {
431 let mut orchestrator = PromptBuilder::new(std::env::current_dir().unwrap())
432 .profile(PromptProfile::Review)
433 .add_static("identity", "You are a code reviewer.")
434 .add_dynamic("date", || "Today".to_string())
435 .build();
436
437 let assembled = orchestrator.assemble();
438 assert!(assembled.prompt.contains("identity"));
439 }
440
441 #[test]
442 fn test_cache_efficiency() {
443 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
444 .with_context_injection(false);
445
446 let static_content = "static content that should be cached properly test test test";
448 orchestrator.add_section(PromptSection::static_section("big", static_content));
449 orchestrator.add_section(PromptSection::dynamic_section("small", || "dynamic".to_string()));
450
451 let assembled = orchestrator.assemble();
452 let efficiency = assembled.cache_efficiency();
453
454 assert!(efficiency >= 50.0, "Cache efficiency: {}", efficiency);
456 }
457
458 #[test]
459 fn test_invalidate_cache() {
460 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
461 orchestrator.add_section(PromptSection::static_section("test", "test content"));
462
463 let _ = orchestrator.assemble();
465
466 orchestrator.invalidate_cache();
468
469 let assembled = orchestrator.assemble();
471 assert!(assembled.prompt.contains("test"));
472 }
473}