1use crate::prompt::{CacheKey, PromptSection, SectionCache};
10use crate::prompt::{ContextInjector, SystemContext, UserContext};
11use std::sync::Arc;
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
146 .get_or_compute(&key, || section.compute_content())
147 } else {
148 section.compute_content()
149 }
150 }
151
152 pub fn assemble(&mut self) -> AssembledPrompt {
154 let mut cached_parts = Vec::new();
155 let mut dynamic_parts = Vec::new();
156 let mut cached_tokens = 0;
157 let mut dynamic_tokens = 0;
158
159 let mut sections = self.sections.clone();
161 sections.sort_by_key(|s| s.order);
162
163 for section in §ions {
165 let content = self.render_section(section);
166
167 if section.cacheable {
168 cached_parts.push((section.name.clone(), content.clone()));
169 cached_tokens += self.estimate_tokens(&content);
170 } else {
171 dynamic_parts.push((section.name.clone(), content.clone()));
172 dynamic_tokens += self.estimate_tokens(&content);
173 }
174 }
175
176 let context_section = if self.inject_context {
178 let ctx = self.context_injector.render_full_context();
179 dynamic_tokens += self.estimate_tokens(&ctx);
180 Some(ctx)
181 } else {
182 None
183 };
184
185 let mut final_parts = Vec::new();
187
188 for (name, content) in &cached_parts {
190 if !content.is_empty() {
191 final_parts.push(format!("[{}]\n{}", name, content));
192 }
193 }
194
195 if self.include_boundary
197 && !cached_parts.is_empty()
198 && (!dynamic_parts.is_empty() || context_section.is_some())
199 {
200 final_parts.push(CACHE_BOUNDARY.to_string());
201 }
202
203 for (name, content) in &dynamic_parts {
205 if !content.is_empty() {
206 final_parts.push(format!("[{}]\n{}", name, content));
207 }
208 }
209
210 if let Some(ctx) = context_section {
212 final_parts.push(ctx);
213 }
214
215 let full_prompt = final_parts.join("\n\n");
216
217 let stats = self.cache.stats();
219
220 AssembledPrompt {
221 prompt: full_prompt,
222 cached_sections: cached_parts.len(),
223 dynamic_sections: dynamic_parts.len(),
224 cached_tokens,
225 dynamic_tokens,
226 total_tokens: cached_tokens + dynamic_tokens,
227 cache_hit_rate: stats.hit_rate(),
228 profile: self.profile,
229 }
230 }
231
232 pub fn assemble_for_profile(&mut self, profile: PromptProfile) -> AssembledPrompt {
234 self.profile = profile;
235 self.assemble()
236 }
237
238 fn estimate_tokens(&self, content: &str) -> usize {
240 crate::prompt::cache::estimate_tokens(content)
241 }
242
243 pub fn cache_stats(&self) -> crate::prompt::cache::CacheStats {
245 self.cache.stats()
246 }
247
248 pub fn get_user_context(&mut self) -> &UserContext {
250 self.context_injector.get_user_context()
251 }
252
253 pub fn get_system_context(&mut self) -> &SystemContext {
255 self.context_injector.get_system_context()
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct AssembledPrompt {
262 pub prompt: String,
264 pub cached_sections: usize,
266 pub dynamic_sections: usize,
268 pub cached_tokens: usize,
270 pub dynamic_tokens: usize,
272 pub total_tokens: usize,
274 pub cache_hit_rate: f64,
276 pub profile: PromptProfile,
278}
279
280impl AssembledPrompt {
281 pub fn is_empty(&self) -> bool {
283 self.prompt.is_empty()
284 }
285
286 pub fn len(&self) -> usize {
288 self.prompt.len()
289 }
290
291 pub fn cache_efficiency(&self) -> f64 {
293 if self.total_tokens == 0 {
294 0.0
295 } else {
296 (self.cached_tokens as f64 / self.total_tokens as f64) * 100.0
297 }
298 }
299
300 pub fn split_at_boundary(&self) -> (Option<&str>, Option<&str>) {
302 if let Some(idx) = self.prompt.find(CACHE_BOUNDARY) {
303 let cached = &self.prompt[..idx];
304 let dynamic = &self.prompt[idx + CACHE_BOUNDARY.len()..];
305 (
306 if cached.is_empty() {
307 None
308 } else {
309 Some(cached)
310 },
311 if dynamic.is_empty() {
312 None
313 } else {
314 Some(dynamic)
315 },
316 )
317 } else {
318 if self.prompt.is_empty() {
319 (None, None)
320 } else {
321 (Some(&self.prompt), None)
322 }
323 }
324 }
325}
326
327pub struct PromptBuilder {
329 working_dir: std::path::PathBuf,
330 profile: PromptProfile,
331 sections: Vec<PromptSection>,
332 include_boundary: bool,
333 inject_context: bool,
334}
335
336impl PromptBuilder {
337 pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
338 Self {
339 working_dir: working_dir.into(),
340 profile: PromptProfile::Default,
341 sections: Vec::new(),
342 include_boundary: true,
343 inject_context: true,
344 }
345 }
346
347 pub fn profile(mut self, profile: PromptProfile) -> Self {
348 self.profile = profile;
349 self
350 }
351
352 pub fn add_section(mut self, section: PromptSection) -> Self {
353 self.sections.push(section);
354 self
355 }
356
357 pub fn add_static(self, name: impl Into<String>, content: &'static str) -> Self {
358 self.add_section(PromptSection::static_section(name, content))
359 }
360
361 pub fn add_dynamic<F>(self, name: impl Into<String>, compute: F) -> Self
362 where
363 F: Fn() -> String + Send + Sync + 'static,
364 {
365 self.add_section(PromptSection::dynamic_section(name, compute))
366 }
367
368 pub fn no_boundary(mut self) -> Self {
369 self.include_boundary = false;
370 self
371 }
372
373 pub fn no_context(mut self) -> Self {
374 self.inject_context = false;
375 self
376 }
377
378 pub fn build(self) -> PromptOrchestrator {
379 let mut orchestrator = PromptOrchestrator::new(self.working_dir)
380 .with_profile(self.profile)
381 .with_boundary(self.include_boundary)
382 .with_context_injection(self.inject_context);
383 orchestrator.add_sections(self.sections);
384 orchestrator
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_assemble_simple() {
394 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
395 orchestrator.add_section(PromptSection::static_section(
396 "identity",
397 "You are an AI assistant.",
398 ));
399
400 let assembled = orchestrator.assemble();
401 assert!(!assembled.prompt.is_empty());
402 assert!(assembled.prompt.contains("identity"));
403 assert!(assembled.cached_sections >= 1);
404 }
405
406 #[test]
407 fn test_assemble_with_dynamic() {
408 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
409 orchestrator.add_section(PromptSection::static_section("identity", "You are an AI."));
410 orchestrator.add_section(PromptSection::dynamic_section("date", || {
411 format!("Current date: {}", chrono::Local::now().format("%Y-%m-%d"))
412 }));
413
414 let assembled = orchestrator.assemble();
415 assert!(assembled.dynamic_sections >= 1);
416 assert!(assembled.cached_sections >= 1);
417 }
418
419 #[test]
420 fn test_cache_boundary() {
421 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
422 .with_boundary(true)
423 .with_context_injection(false);
424
425 orchestrator.add_section(PromptSection::static_section("cached", "cached content"));
426 orchestrator.add_section(PromptSection::dynamic_section("dynamic", || {
427 "dynamic content".to_string()
428 }));
429
430 let assembled = orchestrator.assemble();
431 assert!(assembled.prompt.contains(CACHE_BOUNDARY));
432
433 let (cached, dynamic) = assembled.split_at_boundary();
434 assert!(cached.is_some());
435 assert!(dynamic.is_some());
436 }
437
438 #[test]
439 fn test_profile() {
440 let orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
441 .with_profile(PromptProfile::Fast);
442
443 assert_eq!(orchestrator.profile, PromptProfile::Fast);
444 }
445
446 #[test]
447 fn test_builder() {
448 let mut orchestrator = PromptBuilder::new(std::env::current_dir().unwrap())
449 .profile(PromptProfile::Review)
450 .add_static("identity", "You are a code reviewer.")
451 .add_dynamic("date", || "Today".to_string())
452 .build();
453
454 let assembled = orchestrator.assemble();
455 assert!(assembled.prompt.contains("identity"));
456 }
457
458 #[test]
459 fn test_cache_efficiency() {
460 let mut orchestrator =
461 PromptOrchestrator::new(std::env::current_dir().unwrap()).with_context_injection(false);
462
463 let static_content = "static content that should be cached properly test test test";
465 orchestrator.add_section(PromptSection::static_section("big", static_content));
466 orchestrator.add_section(PromptSection::dynamic_section("small", || {
467 "dynamic".to_string()
468 }));
469
470 let assembled = orchestrator.assemble();
471 let efficiency = assembled.cache_efficiency();
472
473 assert!(efficiency >= 50.0, "Cache efficiency: {}", efficiency);
475 }
476
477 #[test]
478 fn test_invalidate_cache() {
479 let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
480 orchestrator.add_section(PromptSection::static_section("test", "test content"));
481
482 let _ = orchestrator.assemble();
484
485 orchestrator.invalidate_cache();
487
488 let assembled = orchestrator.assemble();
490 assert!(assembled.prompt.contains("test"));
491 }
492}