llm_toolkit_expertise/render.rs
1//! Context-aware prompt rendering (Phase 2)
2//!
3//! This module provides context-aware prompt generation capabilities,
4//! allowing dynamic filtering and ordering of knowledge fragments based
5//! on runtime context.
6
7use crate::context::{ContextMatcher, ContextProfile, TaskHealth};
8
9/// Runtime context for prompt rendering
10///
11/// Encapsulates the current state that determines which knowledge fragments
12/// should be included and how they should be prioritized.
13///
14/// # Examples
15///
16/// ```
17/// use llm_toolkit_expertise::render::RenderContext;
18/// use llm_toolkit_expertise::context::TaskHealth;
19///
20/// let context = RenderContext::new()
21/// .with_task_type("security-review")
22/// .with_user_state("beginner")
23/// .with_task_health(TaskHealth::AtRisk);
24/// ```
25#[derive(Debug, Clone, Default, PartialEq)]
26pub struct RenderContext {
27 /// Current task type (e.g., "security-review", "code-review", "debug")
28 pub task_type: Option<String>,
29
30 /// User states (e.g., "beginner", "expert", "confused")
31 pub user_states: Vec<String>,
32
33 /// Current task health status
34 pub task_health: Option<TaskHealth>,
35}
36
37impl RenderContext {
38 /// Create a new empty render context
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 /// Set the task type
44 ///
45 /// # Examples
46 ///
47 /// ```
48 /// use llm_toolkit_expertise::render::RenderContext;
49 ///
50 /// let context = RenderContext::new()
51 /// .with_task_type("security-review");
52 /// ```
53 pub fn with_task_type(mut self, task_type: impl Into<String>) -> Self {
54 self.task_type = Some(task_type.into());
55 self
56 }
57
58 /// Add a user state
59 ///
60 /// # Examples
61 ///
62 /// ```
63 /// use llm_toolkit_expertise::render::RenderContext;
64 ///
65 /// let context = RenderContext::new()
66 /// .with_user_state("beginner")
67 /// .with_user_state("confused");
68 /// ```
69 pub fn with_user_state(mut self, state: impl Into<String>) -> Self {
70 self.user_states.push(state.into());
71 self
72 }
73
74 /// Set the task health
75 ///
76 /// # Examples
77 ///
78 /// ```
79 /// use llm_toolkit_expertise::render::RenderContext;
80 /// use llm_toolkit_expertise::context::TaskHealth;
81 ///
82 /// let context = RenderContext::new()
83 /// .with_task_health(TaskHealth::AtRisk);
84 /// ```
85 pub fn with_task_health(mut self, health: TaskHealth) -> Self {
86 self.task_health = Some(health);
87 self
88 }
89
90 /// Check if this context matches a ContextProfile
91 ///
92 /// A context matches a profile if:
93 /// - Profile is `Always` → always matches
94 /// - Profile is `Conditional`:
95 /// - If `task_types` is non-empty, current task_type must be in the list
96 /// - If `user_states` is non-empty, at least one user_state must match
97 /// - If `task_health` is set, current health must match
98 ///
99 /// # Examples
100 ///
101 /// ```
102 /// use llm_toolkit_expertise::render::RenderContext;
103 /// use llm_toolkit_expertise::context::{ContextProfile, TaskHealth};
104 ///
105 /// let context = RenderContext::new()
106 /// .with_task_type("security-review")
107 /// .with_task_health(TaskHealth::AtRisk);
108 ///
109 /// let profile = ContextProfile::Conditional {
110 /// task_types: vec!["security-review".to_string()],
111 /// user_states: vec![],
112 /// task_health: Some(TaskHealth::AtRisk),
113 /// };
114 ///
115 /// assert!(context.matches(&profile));
116 /// ```
117 pub fn matches(&self, profile: &ContextProfile) -> bool {
118 match profile {
119 ContextProfile::Always => true,
120 ContextProfile::Conditional {
121 task_types,
122 user_states,
123 task_health,
124 } => {
125 // Check task_type match
126 let task_type_match = if task_types.is_empty() {
127 true // No task type constraint
128 } else {
129 self.task_type
130 .as_ref()
131 .map(|tt| task_types.contains(tt))
132 .unwrap_or(false)
133 };
134
135 // Check user_state match (at least one must match)
136 let user_state_match = if user_states.is_empty() {
137 true // No user state constraint
138 } else {
139 self.user_states
140 .iter()
141 .any(|state| user_states.contains(state))
142 };
143
144 // Check task_health match
145 let task_health_match = if let Some(required_health) = task_health {
146 self.task_health.as_ref() == Some(required_health)
147 } else {
148 true // No health constraint
149 };
150
151 task_type_match && user_state_match && task_health_match
152 }
153 }
154 }
155
156 /// Convert to legacy ContextMatcher for backward compatibility
157 ///
158 /// Note: ContextMatcher only supports a single user_state, so the first
159 /// user_state from the Vec will be used.
160 pub fn to_context_matcher(&self) -> ContextMatcher {
161 ContextMatcher {
162 task_type: self.task_type.clone(),
163 user_state: self.user_states.first().cloned(),
164 task_health: self.task_health,
165 }
166 }
167}
168
169/// Convert from legacy ContextMatcher
170impl From<ContextMatcher> for RenderContext {
171 fn from(matcher: ContextMatcher) -> Self {
172 let mut user_states = Vec::new();
173 if let Some(state) = matcher.user_state {
174 user_states.push(state);
175 }
176
177 Self {
178 task_type: matcher.task_type,
179 user_states,
180 task_health: matcher.task_health,
181 }
182 }
183}
184
185/// Context-aware prompt renderer (Phase 2)
186///
187/// A wrapper type that combines an `Expertise` with a `RenderContext` to enable
188/// context-aware prompt generation. Implements `ToPrompt` for seamless integration
189/// with the DTO pattern.
190///
191/// # Examples
192///
193/// ## Direct Usage
194///
195/// ```
196/// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
197/// use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
198/// use llm_toolkit_expertise::context::TaskHealth;
199///
200/// let expertise = Expertise::new("rust-reviewer", "1.0")
201/// .with_fragment(WeightedFragment::new(
202/// KnowledgeFragment::Text("Review Rust code".to_string())
203/// ));
204///
205/// let prompt = ContextualPrompt::from_expertise(&expertise, RenderContext::new())
206/// .with_task_type("security-review")
207/// .with_task_health(TaskHealth::AtRisk)
208/// .to_prompt();
209/// ```
210///
211/// ## DTO Integration
212///
213/// ```
214/// # use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
215/// # use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
216/// // With ToPrompt-based DTO pattern:
217/// // #[derive(ToPrompt)]
218/// // #[prompt(template = "Knowledge:\n{{expertise}}\n\nTask: {{task}}")]
219/// // struct RequestDto<'a> {
220/// // expertise: ContextualPrompt<'a>,
221/// // task: String,
222/// // }
223/// ```
224#[derive(Debug, Clone)]
225pub struct ContextualPrompt<'a> {
226 expertise: &'a crate::types::Expertise,
227 context: RenderContext,
228}
229
230impl<'a> ContextualPrompt<'a> {
231 /// Create a contextual prompt from expertise and context
232 ///
233 /// # Examples
234 ///
235 /// ```
236 /// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
237 /// use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
238 ///
239 /// let expertise = Expertise::new("test", "1.0")
240 /// .with_fragment(WeightedFragment::new(
241 /// KnowledgeFragment::Text("Test".to_string())
242 /// ));
243 ///
244 /// let prompt = ContextualPrompt::from_expertise(&expertise, RenderContext::new());
245 /// ```
246 pub fn from_expertise(expertise: &'a crate::types::Expertise, context: RenderContext) -> Self {
247 Self { expertise, context }
248 }
249
250 /// Set the task type
251 ///
252 /// # Examples
253 ///
254 /// ```
255 /// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
256 /// use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
257 ///
258 /// let expertise = Expertise::new("test", "1.0")
259 /// .with_fragment(WeightedFragment::new(
260 /// KnowledgeFragment::Text("Test".to_string())
261 /// ));
262 ///
263 /// let prompt = ContextualPrompt::from_expertise(&expertise, RenderContext::new())
264 /// .with_task_type("security-review");
265 /// ```
266 pub fn with_task_type(mut self, task_type: impl Into<String>) -> Self {
267 self.context = self.context.with_task_type(task_type);
268 self
269 }
270
271 /// Add a user state
272 ///
273 /// # Examples
274 ///
275 /// ```
276 /// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
277 /// use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
278 ///
279 /// let expertise = Expertise::new("test", "1.0")
280 /// .with_fragment(WeightedFragment::new(
281 /// KnowledgeFragment::Text("Test".to_string())
282 /// ));
283 ///
284 /// let prompt = ContextualPrompt::from_expertise(&expertise, RenderContext::new())
285 /// .with_user_state("beginner");
286 /// ```
287 pub fn with_user_state(mut self, state: impl Into<String>) -> Self {
288 self.context = self.context.with_user_state(state);
289 self
290 }
291
292 /// Set the task health
293 ///
294 /// # Examples
295 ///
296 /// ```
297 /// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
298 /// use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
299 /// use llm_toolkit_expertise::context::TaskHealth;
300 ///
301 /// let expertise = Expertise::new("test", "1.0")
302 /// .with_fragment(WeightedFragment::new(
303 /// KnowledgeFragment::Text("Test".to_string())
304 /// ));
305 ///
306 /// let prompt = ContextualPrompt::from_expertise(&expertise, RenderContext::new())
307 /// .with_task_health(TaskHealth::AtRisk);
308 /// ```
309 pub fn with_task_health(mut self, health: TaskHealth) -> Self {
310 self.context = self.context.with_task_health(health);
311 self
312 }
313
314 /// Render the prompt with context
315 ///
316 /// This is called automatically when using `ToPrompt` trait.
317 ///
318 /// # Examples
319 ///
320 /// ```
321 /// use llm_toolkit_expertise::{Expertise, WeightedFragment, KnowledgeFragment};
322 /// use llm_toolkit_expertise::render::{ContextualPrompt, RenderContext};
323 ///
324 /// let expertise = Expertise::new("test", "1.0")
325 /// .with_fragment(WeightedFragment::new(
326 /// KnowledgeFragment::Text("Test content".to_string())
327 /// ));
328 ///
329 /// let prompt = ContextualPrompt::from_expertise(&expertise, RenderContext::new())
330 /// .to_prompt();
331 ///
332 /// assert!(prompt.contains("Test content"));
333 /// ```
334 pub fn to_prompt(&self) -> String {
335 self.expertise.to_prompt_with_render_context(&self.context)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_render_context_builder() {
345 let context = RenderContext::new()
346 .with_task_type("security-review")
347 .with_user_state("beginner")
348 .with_task_health(TaskHealth::AtRisk);
349
350 assert_eq!(context.task_type, Some("security-review".to_string()));
351 assert_eq!(context.user_states, vec!["beginner"]);
352 assert_eq!(context.task_health, Some(TaskHealth::AtRisk));
353 }
354
355 #[test]
356 fn test_matches_always() {
357 let context = RenderContext::new();
358 assert!(context.matches(&ContextProfile::Always));
359 }
360
361 #[test]
362 fn test_matches_task_type() {
363 let context = RenderContext::new().with_task_type("security-review");
364
365 let profile = ContextProfile::Conditional {
366 task_types: vec!["security-review".to_string()],
367 user_states: vec![],
368 task_health: None,
369 };
370
371 assert!(context.matches(&profile));
372
373 let wrong_profile = ContextProfile::Conditional {
374 task_types: vec!["code-review".to_string()],
375 user_states: vec![],
376 task_health: None,
377 };
378
379 assert!(!context.matches(&wrong_profile));
380 }
381
382 #[test]
383 fn test_matches_user_state() {
384 let context = RenderContext::new()
385 .with_user_state("beginner")
386 .with_user_state("confused");
387
388 let profile = ContextProfile::Conditional {
389 task_types: vec![],
390 user_states: vec!["beginner".to_string()],
391 task_health: None,
392 };
393
394 assert!(context.matches(&profile));
395
396 let profile2 = ContextProfile::Conditional {
397 task_types: vec![],
398 user_states: vec!["expert".to_string()],
399 task_health: None,
400 };
401
402 assert!(!context.matches(&profile2));
403 }
404
405 #[test]
406 fn test_matches_task_health() {
407 let context = RenderContext::new().with_task_health(TaskHealth::AtRisk);
408
409 let profile = ContextProfile::Conditional {
410 task_types: vec![],
411 user_states: vec![],
412 task_health: Some(TaskHealth::AtRisk),
413 };
414
415 assert!(context.matches(&profile));
416
417 let wrong_profile = ContextProfile::Conditional {
418 task_types: vec![],
419 user_states: vec![],
420 task_health: Some(TaskHealth::OnTrack),
421 };
422
423 assert!(!context.matches(&wrong_profile));
424 }
425
426 #[test]
427 fn test_matches_combined() {
428 let context = RenderContext::new()
429 .with_task_type("security-review")
430 .with_user_state("beginner")
431 .with_task_health(TaskHealth::AtRisk);
432
433 let profile = ContextProfile::Conditional {
434 task_types: vec!["security-review".to_string()],
435 user_states: vec!["beginner".to_string()],
436 task_health: Some(TaskHealth::AtRisk),
437 };
438
439 assert!(context.matches(&profile));
440
441 // Missing one condition
442 let partial_profile = ContextProfile::Conditional {
443 task_types: vec!["security-review".to_string()],
444 user_states: vec!["expert".to_string()], // Wrong!
445 task_health: Some(TaskHealth::AtRisk),
446 };
447
448 assert!(!context.matches(&partial_profile));
449 }
450
451 #[test]
452 fn test_matches_no_constraints() {
453 let context = RenderContext::new().with_task_type("anything");
454
455 let profile = ContextProfile::Conditional {
456 task_types: vec![],
457 user_states: vec![],
458 task_health: None,
459 };
460
461 assert!(context.matches(&profile));
462 }
463}