llm_toolkit/
prompt.rs

1//! A trait and macros for powerful, type-safe prompt generation.
2
3use minijinja::Environment;
4use serde::Serialize;
5
6/// Represents a part of a multimodal prompt.
7///
8/// This enum allows prompts to contain different types of content,
9/// such as text and images, enabling multimodal LLM interactions.
10#[derive(Debug, Clone)]
11pub enum PromptPart {
12    /// Text content in the prompt.
13    Text(String),
14    /// Image content with media type and binary data.
15    Image {
16        /// The MIME media type (e.g., "image/jpeg", "image/png").
17        media_type: String,
18        /// The raw image data.
19        data: Vec<u8>,
20    },
21    // Future variants like Audio or Video can be added here
22}
23
24/// A trait for converting any type into a string suitable for an LLM prompt.
25///
26/// This trait provides a standard interface for converting various types
27/// into strings that can be used as prompts for language models.
28///
29/// # Example
30///
31/// ```
32/// use llm_toolkit::prompt::ToPrompt;
33///
34/// // Common types have ToPrompt implementations
35/// let number = 42;
36/// assert_eq!(number.to_prompt(), "42");
37///
38/// let text = "Hello, LLM!";
39/// assert_eq!(text.to_prompt(), "Hello, LLM!");
40/// ```
41///
42/// # Custom Implementation
43///
44/// You can also implement `ToPrompt` directly for your own types:
45///
46/// ```
47/// use llm_toolkit::prompt::{ToPrompt, PromptPart};
48/// use std::fmt;
49///
50/// struct CustomType {
51///     value: String,
52/// }
53///
54/// impl fmt::Display for CustomType {
55///     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56///         write!(f, "{}", self.value)
57///     }
58/// }
59///
60/// // By implementing ToPrompt directly, you can control the conversion.
61/// impl ToPrompt for CustomType {
62///     fn to_prompt_parts(&self) -> Vec<PromptPart> {
63///         vec![PromptPart::Text(self.to_string())]
64///     }
65///
66///     fn to_prompt(&self) -> String {
67///         self.to_string()
68///     }
69/// }
70///
71/// let custom = CustomType { value: "custom".to_string() };
72/// assert_eq!(custom.to_prompt(), "custom");
73/// ```
74pub trait ToPrompt {
75    /// Converts the object into a vector of `PromptPart`s based on a mode.
76    ///
77    /// This is the core method that `derive(ToPrompt)` will implement.
78    /// The `mode` argument allows for different prompt representations, such as:
79    /// - "full": A comprehensive prompt with schema and examples.
80    /// - "schema_only": Just the data structure's schema.
81    /// - "example_only": Just a concrete example.
82    ///
83    /// The default implementation ignores the mode and calls `to_prompt_parts`
84    /// for backward compatibility with manual implementations.
85    fn to_prompt_parts_with_mode(&self, mode: &str) -> Vec<PromptPart> {
86        // Default implementation for backward compatibility
87        let _ = mode; // Unused in default impl
88        self.to_prompt_parts()
89    }
90
91    /// Converts the object into a prompt string based on a mode.
92    ///
93    /// This method extracts only the text portions from `to_prompt_parts_with_mode()`.
94    fn to_prompt_with_mode(&self, mode: &str) -> String {
95        self.to_prompt_parts_with_mode(mode)
96            .iter()
97            .filter_map(|part| match part {
98                PromptPart::Text(text) => Some(text.as_str()),
99                _ => None,
100            })
101            .collect::<Vec<_>>()
102            .join("")
103    }
104
105    /// Converts the object into a vector of `PromptPart`s using the default "full" mode.
106    ///
107    /// This method enables multimodal prompt generation by returning
108    /// a collection of prompt parts that can include text, images, and
109    /// other media types.
110    fn to_prompt_parts(&self) -> Vec<PromptPart> {
111        self.to_prompt_parts_with_mode("full")
112    }
113
114    /// Converts the object into a prompt string using the default "full" mode.
115    ///
116    /// This method provides backward compatibility by extracting only
117    /// the text portions from `to_prompt_parts()` and joining them.
118    fn to_prompt(&self) -> String {
119        self.to_prompt_with_mode("full")
120    }
121}
122
123// Add implementations for common types
124
125impl ToPrompt for String {
126    fn to_prompt_parts(&self) -> Vec<PromptPart> {
127        vec![PromptPart::Text(self.clone())]
128    }
129
130    fn to_prompt(&self) -> String {
131        self.clone()
132    }
133}
134
135impl ToPrompt for &str {
136    fn to_prompt_parts(&self) -> Vec<PromptPart> {
137        vec![PromptPart::Text(self.to_string())]
138    }
139
140    fn to_prompt(&self) -> String {
141        self.to_string()
142    }
143}
144
145impl ToPrompt for bool {
146    fn to_prompt_parts(&self) -> Vec<PromptPart> {
147        vec![PromptPart::Text(self.to_string())]
148    }
149
150    fn to_prompt(&self) -> String {
151        self.to_string()
152    }
153}
154
155impl ToPrompt for char {
156    fn to_prompt_parts(&self) -> Vec<PromptPart> {
157        vec![PromptPart::Text(self.to_string())]
158    }
159
160    fn to_prompt(&self) -> String {
161        self.to_string()
162    }
163}
164
165macro_rules! impl_to_prompt_for_numbers {
166    ($($t:ty),*) => {
167        $(
168            impl ToPrompt for $t {
169                fn to_prompt_parts(&self) -> Vec<PromptPart> {
170                    vec![PromptPart::Text(self.to_string())]
171                }
172
173                fn to_prompt(&self) -> String {
174                    self.to_string()
175                }
176            }
177        )*
178    };
179}
180
181impl_to_prompt_for_numbers!(
182    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
183);
184
185/// Renders a prompt from a template string and a serializable context.
186///
187/// This is the underlying function for the `prompt!` macro.
188pub fn render_prompt<T: Serialize>(template: &str, context: T) -> Result<String, minijinja::Error> {
189    let mut env = Environment::new();
190    env.add_template("prompt", template)?;
191    let tmpl = env.get_template("prompt")?;
192    tmpl.render(context)
193}
194
195/// Creates a prompt string from a template and key-value pairs.
196///
197/// This macro provides a `println!`-like experience for building prompts
198/// from various data sources. It leverages `minijinja` for templating.
199///
200/// # Example
201///
202/// ```
203/// use llm_toolkit::prompt;
204/// use serde::Serialize;
205///
206/// #[derive(Serialize)]
207/// struct User {
208///     name: &'static str,
209///     role: &'static str,
210/// }
211///
212/// let user = User { name: "Mai", role: "UX Engineer" };
213/// let task = "designing a new macro";
214///
215/// let p = prompt!(
216///     "User {{user.name}} ({{user.role}}) is currently {{task}}.",
217///     user = user,
218///     task = task
219/// ).unwrap();
220///
221/// assert_eq!(p, "User Mai (UX Engineer) is currently designing a new macro.");
222/// ```
223#[macro_export]
224macro_rules! prompt {
225    ($template:expr, $($key:ident = $value:expr),* $(,)?) => {
226        $crate::prompt::render_prompt($template, minijinja::context!($($key => $value),*))
227    };
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use serde::Serialize;
234    use std::fmt::Display;
235
236    enum TestEnum {
237        VariantA,
238        VariantB,
239    }
240
241    impl Display for TestEnum {
242        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243            match self {
244                TestEnum::VariantA => write!(f, "Variant A"),
245                TestEnum::VariantB => write!(f, "Variant B"),
246            }
247        }
248    }
249
250    impl ToPrompt for TestEnum {
251        fn to_prompt_parts(&self) -> Vec<PromptPart> {
252            vec![PromptPart::Text(self.to_string())]
253        }
254
255        fn to_prompt(&self) -> String {
256            self.to_string()
257        }
258    }
259
260    #[test]
261    fn test_to_prompt_for_enum() {
262        let variant = TestEnum::VariantA;
263        assert_eq!(variant.to_prompt(), "Variant A");
264    }
265
266    #[test]
267    fn test_to_prompt_for_enum_variant_b() {
268        let variant = TestEnum::VariantB;
269        assert_eq!(variant.to_prompt(), "Variant B");
270    }
271
272    #[test]
273    fn test_to_prompt_for_string() {
274        let s = "hello world";
275        assert_eq!(s.to_prompt(), "hello world");
276    }
277
278    #[test]
279    fn test_to_prompt_for_number() {
280        let n = 42;
281        assert_eq!(n.to_prompt(), "42");
282    }
283
284    #[derive(Serialize)]
285    struct SystemInfo {
286        version: &'static str,
287        os: &'static str,
288    }
289
290    #[test]
291    fn test_prompt_macro_simple() {
292        let user = "Yui";
293        let task = "implementation";
294        let prompt = prompt!(
295            "User {{user}} is working on the {{task}}.",
296            user = user,
297            task = task
298        )
299        .unwrap();
300        assert_eq!(prompt, "User Yui is working on the implementation.");
301    }
302
303    #[test]
304    fn test_prompt_macro_with_struct() {
305        let sys = SystemInfo {
306            version: "0.1.0",
307            os: "Rust",
308        };
309        let prompt = prompt!("System: {{sys.version}} on {{sys.os}}", sys = sys).unwrap();
310        assert_eq!(prompt, "System: 0.1.0 on Rust");
311    }
312
313    #[test]
314    fn test_prompt_macro_mixed() {
315        let user = "Mai";
316        let sys = SystemInfo {
317            version: "0.1.0",
318            os: "Rust",
319        };
320        let prompt = prompt!(
321            "User {{user}} is using {{sys.os}} v{{sys.version}}.",
322            user = user,
323            sys = sys
324        )
325        .unwrap();
326        assert_eq!(prompt, "User Mai is using Rust v0.1.0.");
327    }
328
329    #[test]
330    fn test_prompt_macro_no_args() {
331        let prompt = prompt!("This is a static prompt.",).unwrap();
332        assert_eq!(prompt, "This is a static prompt.");
333    }
334}
335
336#[derive(Debug, thiserror::Error)]
337pub enum PromptSetError {
338    #[error("Target '{target}' not found. Available targets: {available:?}")]
339    TargetNotFound {
340        target: String,
341        available: Vec<String>,
342    },
343    #[error("Failed to render prompt for target '{target}': {source}")]
344    RenderFailed {
345        target: String,
346        source: minijinja::Error,
347    },
348}
349
350/// A trait for types that can generate multiple named prompt targets.
351///
352/// This trait enables a single data structure to produce different prompt formats
353/// for various use cases (e.g., human-readable vs. machine-parsable formats).
354///
355/// # Example
356///
357/// ```ignore
358/// use llm_toolkit::prompt::{ToPromptSet, PromptPart};
359/// use serde::Serialize;
360///
361/// #[derive(ToPromptSet, Serialize)]
362/// #[prompt_for(name = "Visual", template = "## {{title}}\n\n> {{description}}")]
363/// struct Task {
364///     title: String,
365///     description: String,
366///
367///     #[prompt_for(name = "Agent")]
368///     priority: u8,
369///
370///     #[prompt_for(name = "Agent", rename = "internal_id")]
371///     id: u64,
372///
373///     #[prompt_for(skip)]
374///     is_dirty: bool,
375/// }
376///
377/// let task = Task {
378///     title: "Implement feature".to_string(),
379///     description: "Add new functionality".to_string(),
380///     priority: 1,
381///     id: 42,
382///     is_dirty: false,
383/// };
384///
385/// // Generate visual prompt
386/// let visual_prompt = task.to_prompt_for("Visual")?;
387///
388/// // Generate agent prompt
389/// let agent_prompt = task.to_prompt_for("Agent")?;
390/// ```
391pub trait ToPromptSet {
392    /// Generates multimodal prompt parts for the specified target.
393    fn to_prompt_parts_for(&self, target: &str) -> Result<Vec<PromptPart>, PromptSetError>;
394
395    /// Generates a text prompt for the specified target.
396    ///
397    /// This method extracts only the text portions from `to_prompt_parts_for()`
398    /// and joins them together.
399    fn to_prompt_for(&self, target: &str) -> Result<String, PromptSetError> {
400        let parts = self.to_prompt_parts_for(target)?;
401        let text = parts
402            .iter()
403            .filter_map(|part| match part {
404                PromptPart::Text(text) => Some(text.as_str()),
405                _ => None,
406            })
407            .collect::<Vec<_>>()
408            .join("\n");
409        Ok(text)
410    }
411}
412
413/// A trait for generating a prompt for a specific target type.
414///
415/// This allows a type (e.g., a `Tool`) to define how it should be represented
416/// in a prompt when provided with a target context (e.g., an `Agent`).
417pub trait ToPromptFor<T> {
418    /// Generates a prompt for the given target, using a specific mode.
419    fn to_prompt_for_with_mode(&self, target: &T, mode: &str) -> String;
420
421    /// Generates a prompt for the given target using the default "full" mode.
422    ///
423    /// This method provides backward compatibility by calling the `_with_mode`
424    /// variant with a default mode.
425    fn to_prompt_for(&self, target: &T) -> String {
426        self.to_prompt_for_with_mode(target, "full")
427    }
428}