1use std::collections::HashMap;
10use std::fmt;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ContextCategory {
19 SystemPrompt,
20 FileReference,
21 DirectoryListing,
22 ConversationHistory,
23 PlaybookStrategy,
24 ImageContent,
25 PdfContent,
26 ToolResult,
27 UserQuery,
28}
29
30impl ContextCategory {
31 pub fn as_str(&self) -> &'static str {
32 match self {
33 Self::SystemPrompt => "system_prompt",
34 Self::FileReference => "file_reference",
35 Self::DirectoryListing => "directory_listing",
36 Self::ConversationHistory => "conversation_history",
37 Self::PlaybookStrategy => "playbook_strategy",
38 Self::ImageContent => "image_content",
39 Self::PdfContent => "pdf_content",
40 Self::ToolResult => "tool_result",
41 Self::UserQuery => "user_query",
42 }
43 }
44}
45
46impl fmt::Display for ContextCategory {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 f.write_str(self.as_str())
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ContextReason {
58 pub source: String,
59 pub reason: String,
60 #[serde(default = "default_relevance")]
61 pub relevance_score: f64,
62 #[serde(default)]
63 pub tokens_estimate: usize,
64 #[serde(default)]
65 pub metadata: HashMap<String, serde_json::Value>,
66}
67
68fn default_relevance() -> f64 {
69 1.0
70}
71
72impl ContextReason {
73 pub fn new(source: &str, reason: &str) -> Self {
74 Self {
75 source: source.to_string(),
76 reason: reason.to_string(),
77 relevance_score: 1.0,
78 tokens_estimate: 0,
79 metadata: HashMap::new(),
80 }
81 }
82
83 pub fn with_tokens(mut self, tokens: usize) -> Self {
84 self.tokens_estimate = tokens;
85 self
86 }
87
88 pub fn with_score(mut self, score: f64) -> Self {
89 self.relevance_score = score;
90 self
91 }
92}
93
94impl fmt::Display for ContextReason {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 let score_str = if self.relevance_score < 1.0 {
97 format!(" (score={:.2})", self.relevance_score)
98 } else {
99 String::new()
100 };
101 let tokens_str = if self.tokens_estimate > 0 {
102 format!(" [{} tokens]", self.tokens_estimate)
103 } else {
104 String::new()
105 };
106 write!(
107 f,
108 "[{}]{}{}: {}",
109 self.source, score_str, tokens_str, self.reason
110 )
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct ContextPiece {
117 pub content: String,
118 pub reason: ContextReason,
119 pub category: ContextCategory,
120 #[serde(default = "default_order")]
122 pub order: i32,
123}
124
125fn default_order() -> i32 {
126 100
127}
128
129impl ContextPiece {
130 pub fn new(content: String, reason: ContextReason, category: ContextCategory) -> Self {
131 Self {
132 content,
133 reason,
134 category,
135 order: 100,
136 }
137 }
138
139 pub fn with_order(mut self, order: i32) -> Self {
140 self.order = order;
141 self
142 }
143
144 pub fn tokens_estimate(&self) -> usize {
146 if self.reason.tokens_estimate > 0 {
147 return self.reason.tokens_estimate;
148 }
149 self.content.len() / 4
150 }
151}
152
153impl fmt::Display for ContextPiece {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 let preview: String = self.content.chars().take(50).collect();
156 let preview = preview.replace('\n', "\\n");
157 let ellipsis = if self.content.len() > 50 { "..." } else { "" };
158 write!(f, "{}: {}{}", self.category, preview, ellipsis)
159 }
160}
161
162#[derive(Debug, Clone, Default, Serialize, Deserialize)]
166pub struct AssembledContext {
167 pub system_prompt: String,
168 pub messages: Vec<serde_json::Map<String, serde_json::Value>>,
169 #[serde(default)]
170 pub pieces: Vec<ContextPiece>,
171 #[serde(default)]
172 pub image_blocks: Vec<serde_json::Value>,
173 #[serde(default)]
174 pub total_tokens_estimate: usize,
175}
176
177impl AssembledContext {
178 pub fn summary(&self) -> String {
180 let mut by_category: HashMap<ContextCategory, Vec<&ContextPiece>> = HashMap::new();
181 for piece in &self.pieces {
182 by_category.entry(piece.category).or_default().push(piece);
183 }
184
185 let mut parts = Vec::new();
186 for (category, pieces) in &by_category {
187 let total_tokens: usize = pieces.iter().map(|p| p.tokens_estimate()).sum();
188 parts.push(format!("{}: ~{} tokens", category, total_tokens));
189 }
190
191 let mut summary = format!("Context: {} tokens", self.total_tokens_estimate);
192 if !parts.is_empty() {
193 summary = format!("{} ({})", summary, parts.join(", "));
194 }
195 summary
196 }
197}
198
199pub struct ContextTracer;
201
202impl ContextTracer {
203 pub fn new() -> Self {
204 Self
205 }
206
207 pub fn trace(&self, context: &AssembledContext) {
208 tracing::debug!("[ContextPicker] {}", context.summary());
209 }
210
211 pub fn export_trace(&self, context: &AssembledContext, path: &Path) -> std::io::Result<()> {
213 let trace_data = serde_json::json!({
214 "timestamp": chrono::Utc::now().to_rfc3339(),
215 "total_tokens_estimate": context.total_tokens_estimate,
216 "message_count": context.messages.len(),
217 "piece_count": context.pieces.len(),
218 "image_count": context.image_blocks.len(),
219 "pieces": context.pieces.iter().map(|p| {
220 serde_json::json!({
221 "category": p.category.as_str(),
222 "source": p.reason.source,
223 "tokens_estimate": p.tokens_estimate(),
224 })
225 }).collect::<Vec<_>>(),
226 });
227
228 if let Some(parent) = path.parent() {
229 std::fs::create_dir_all(parent)?;
230 }
231 let file = std::fs::File::create(path)?;
232 serde_json::to_writer_pretty(file, &trace_data)?;
233 tracing::debug!("Context trace exported to {}", path.display());
234 Ok(())
235 }
236}
237
238impl Default for ContextTracer {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244#[cfg(test)]
245#[path = "context_picker_tests.rs"]
246mod tests;