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}