use crate::prompt::{CacheKey, PromptSection, SectionCache};
use crate::prompt::{ContextInjector, SystemContext, UserContext};
use std::sync::Arc;
pub const CACHE_BOUNDARY: &str = "\n<!-- CACHE_BOUNDARY -->\n";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PromptProfile {
Default,
Safe,
Fast,
Review,
}
impl PromptProfile {
pub fn as_str(&self) -> &'static str {
match self {
Self::Default => "default",
Self::Safe => "safe",
Self::Fast => "fast",
Self::Review => "review",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"safe" => Self::Safe,
"fast" => Self::Fast,
"review" => Self::Review,
_ => Self::Default,
}
}
}
impl std::fmt::Display for PromptProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl Default for PromptProfile {
fn default() -> Self {
Self::Default
}
}
pub struct PromptOrchestrator {
cache: Arc<SectionCache>,
context_injector: ContextInjector,
profile: PromptProfile,
sections: Vec<PromptSection>,
include_boundary: bool,
inject_context: bool,
}
impl PromptOrchestrator {
pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
Self {
cache: Arc::new(SectionCache::new()),
context_injector: ContextInjector::new(working_dir.into()),
profile: PromptProfile::Default,
sections: Vec::new(),
include_boundary: true,
inject_context: true,
}
}
pub fn with_cache(mut self, cache: Arc<SectionCache>) -> Self {
self.cache = cache;
self
}
pub fn with_profile(mut self, profile: PromptProfile) -> Self {
self.profile = profile;
self
}
pub fn with_boundary(mut self, include: bool) -> Self {
self.include_boundary = include;
self
}
pub fn with_context_injection(mut self, inject: bool) -> Self {
self.inject_context = inject;
self
}
pub fn add_section(&mut self, section: PromptSection) -> &mut Self {
self.sections.push(section);
self
}
pub fn add_sections(&mut self, sections: Vec<PromptSection>) -> &mut Self {
self.sections.extend(sections);
self
}
pub fn clear_sections(&mut self) -> &mut Self {
self.sections.clear();
self
}
pub fn invalidate_cache(&mut self) {
self.cache.clear();
self.context_injector.invalidate();
}
fn render_section(&self, section: &PromptSection) -> String {
if section.cacheable {
let key = CacheKey::new(§ion.name, self.profile.as_str());
self.cache
.get_or_compute(&key, || section.compute_content())
} else {
section.compute_content()
}
}
pub fn assemble(&mut self) -> AssembledPrompt {
let mut cached_parts = Vec::new();
let mut dynamic_parts = Vec::new();
let mut cached_tokens = 0;
let mut dynamic_tokens = 0;
let mut sections = self.sections.clone();
sections.sort_by_key(|s| s.order);
for section in §ions {
let content = self.render_section(section);
if section.cacheable {
cached_parts.push((section.name.clone(), content.clone()));
cached_tokens += self.estimate_tokens(&content);
} else {
dynamic_parts.push((section.name.clone(), content.clone()));
dynamic_tokens += self.estimate_tokens(&content);
}
}
let context_section = if self.inject_context {
let ctx = self.context_injector.render_full_context();
dynamic_tokens += self.estimate_tokens(&ctx);
Some(ctx)
} else {
None
};
let mut final_parts = Vec::new();
for (name, content) in &cached_parts {
if !content.is_empty() {
final_parts.push(format!("[{}]\n{}", name, content));
}
}
if self.include_boundary
&& !cached_parts.is_empty()
&& (!dynamic_parts.is_empty() || context_section.is_some())
{
final_parts.push(CACHE_BOUNDARY.to_string());
}
for (name, content) in &dynamic_parts {
if !content.is_empty() {
final_parts.push(format!("[{}]\n{}", name, content));
}
}
if let Some(ctx) = context_section {
final_parts.push(ctx);
}
let full_prompt = final_parts.join("\n\n");
let stats = self.cache.stats();
AssembledPrompt {
prompt: full_prompt,
cached_sections: cached_parts.len(),
dynamic_sections: dynamic_parts.len(),
cached_tokens,
dynamic_tokens,
total_tokens: cached_tokens + dynamic_tokens,
cache_hit_rate: stats.hit_rate(),
profile: self.profile,
}
}
pub fn assemble_for_profile(&mut self, profile: PromptProfile) -> AssembledPrompt {
self.profile = profile;
self.assemble()
}
fn estimate_tokens(&self, content: &str) -> usize {
crate::prompt::cache::estimate_tokens(content)
}
pub fn cache_stats(&self) -> crate::prompt::cache::CacheStats {
self.cache.stats()
}
pub fn get_user_context(&mut self) -> &UserContext {
self.context_injector.get_user_context()
}
pub fn get_system_context(&mut self) -> &SystemContext {
self.context_injector.get_system_context()
}
}
#[derive(Debug, Clone)]
pub struct AssembledPrompt {
pub prompt: String,
pub cached_sections: usize,
pub dynamic_sections: usize,
pub cached_tokens: usize,
pub dynamic_tokens: usize,
pub total_tokens: usize,
pub cache_hit_rate: f64,
pub profile: PromptProfile,
}
impl AssembledPrompt {
pub fn is_empty(&self) -> bool {
self.prompt.is_empty()
}
pub fn len(&self) -> usize {
self.prompt.len()
}
pub fn cache_efficiency(&self) -> f64 {
if self.total_tokens == 0 {
0.0
} else {
(self.cached_tokens as f64 / self.total_tokens as f64) * 100.0
}
}
pub fn split_at_boundary(&self) -> (Option<&str>, Option<&str>) {
if let Some(idx) = self.prompt.find(CACHE_BOUNDARY) {
let cached = &self.prompt[..idx];
let dynamic = &self.prompt[idx + CACHE_BOUNDARY.len()..];
(
if cached.is_empty() {
None
} else {
Some(cached)
},
if dynamic.is_empty() {
None
} else {
Some(dynamic)
},
)
} else {
if self.prompt.is_empty() {
(None, None)
} else {
(Some(&self.prompt), None)
}
}
}
}
pub struct PromptBuilder {
working_dir: std::path::PathBuf,
profile: PromptProfile,
sections: Vec<PromptSection>,
include_boundary: bool,
inject_context: bool,
}
impl PromptBuilder {
pub fn new<P: Into<std::path::PathBuf>>(working_dir: P) -> Self {
Self {
working_dir: working_dir.into(),
profile: PromptProfile::Default,
sections: Vec::new(),
include_boundary: true,
inject_context: true,
}
}
pub fn profile(mut self, profile: PromptProfile) -> Self {
self.profile = profile;
self
}
pub fn add_section(mut self, section: PromptSection) -> Self {
self.sections.push(section);
self
}
pub fn add_static(self, name: impl Into<String>, content: &'static str) -> Self {
self.add_section(PromptSection::static_section(name, content))
}
pub fn add_dynamic<F>(self, name: impl Into<String>, compute: F) -> Self
where
F: Fn() -> String + Send + Sync + 'static,
{
self.add_section(PromptSection::dynamic_section(name, compute))
}
pub fn no_boundary(mut self) -> Self {
self.include_boundary = false;
self
}
pub fn no_context(mut self) -> Self {
self.inject_context = false;
self
}
pub fn build(self) -> PromptOrchestrator {
let mut orchestrator = PromptOrchestrator::new(self.working_dir)
.with_profile(self.profile)
.with_boundary(self.include_boundary)
.with_context_injection(self.inject_context);
orchestrator.add_sections(self.sections);
orchestrator
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_assemble_simple() {
let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
orchestrator.add_section(PromptSection::static_section(
"identity",
"You are an AI assistant.",
));
let assembled = orchestrator.assemble();
assert!(!assembled.prompt.is_empty());
assert!(assembled.prompt.contains("identity"));
assert!(assembled.cached_sections >= 1);
}
#[test]
fn test_assemble_with_dynamic() {
let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
orchestrator.add_section(PromptSection::static_section("identity", "You are an AI."));
orchestrator.add_section(PromptSection::dynamic_section("date", || {
format!("Current date: {}", chrono::Local::now().format("%Y-%m-%d"))
}));
let assembled = orchestrator.assemble();
assert!(assembled.dynamic_sections >= 1);
assert!(assembled.cached_sections >= 1);
}
#[test]
fn test_cache_boundary() {
let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
.with_boundary(true)
.with_context_injection(false);
orchestrator.add_section(PromptSection::static_section("cached", "cached content"));
orchestrator.add_section(PromptSection::dynamic_section("dynamic", || {
"dynamic content".to_string()
}));
let assembled = orchestrator.assemble();
assert!(assembled.prompt.contains(CACHE_BOUNDARY));
let (cached, dynamic) = assembled.split_at_boundary();
assert!(cached.is_some());
assert!(dynamic.is_some());
}
#[test]
fn test_profile() {
let orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap())
.with_profile(PromptProfile::Fast);
assert_eq!(orchestrator.profile, PromptProfile::Fast);
}
#[test]
fn test_builder() {
let mut orchestrator = PromptBuilder::new(std::env::current_dir().unwrap())
.profile(PromptProfile::Review)
.add_static("identity", "You are a code reviewer.")
.add_dynamic("date", || "Today".to_string())
.build();
let assembled = orchestrator.assemble();
assert!(assembled.prompt.contains("identity"));
}
#[test]
fn test_cache_efficiency() {
let mut orchestrator =
PromptOrchestrator::new(std::env::current_dir().unwrap()).with_context_injection(false);
let static_content = "static content that should be cached properly test test test";
orchestrator.add_section(PromptSection::static_section("big", static_content));
orchestrator.add_section(PromptSection::dynamic_section("small", || {
"dynamic".to_string()
}));
let assembled = orchestrator.assemble();
let efficiency = assembled.cache_efficiency();
assert!(efficiency >= 50.0, "Cache efficiency: {}", efficiency);
}
#[test]
fn test_invalidate_cache() {
let mut orchestrator = PromptOrchestrator::new(std::env::current_dir().unwrap());
orchestrator.add_section(PromptSection::static_section("test", "test content"));
let _ = orchestrator.assemble();
orchestrator.invalidate_cache();
let assembled = orchestrator.assemble();
assert!(assembled.prompt.contains("test"));
}
}