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    /// Returns a schema-level prompt for the type itself.
123    ///
124    /// For enums, this returns all possible variants with their descriptions.
125    /// For structs, this returns the field schema.
126    ///
127    /// Unlike instance methods like `to_prompt()`, this is a type-level method
128    /// that doesn't require an instance.
129    ///
130    /// # Examples
131    ///
132    /// ```ignore
133    /// // Enum: get all variants
134    /// let schema = MyEnum::prompt_schema();
135    ///
136    /// // Struct: get field schema
137    /// let schema = MyStruct::prompt_schema();
138    /// ```
139    fn prompt_schema() -> String {
140        String::new() // Default implementation returns empty string
141    }
142}
143
144// Add implementations for common types
145
146impl ToPrompt for String {
147    fn to_prompt_parts(&self) -> Vec<PromptPart> {
148        vec![PromptPart::Text(self.clone())]
149    }
150
151    fn to_prompt(&self) -> String {
152        self.clone()
153    }
154}
155
156impl ToPrompt for &str {
157    fn to_prompt_parts(&self) -> Vec<PromptPart> {
158        vec![PromptPart::Text(self.to_string())]
159    }
160
161    fn to_prompt(&self) -> String {
162        self.to_string()
163    }
164}
165
166impl ToPrompt for bool {
167    fn to_prompt_parts(&self) -> Vec<PromptPart> {
168        vec![PromptPart::Text(self.to_string())]
169    }
170
171    fn to_prompt(&self) -> String {
172        self.to_string()
173    }
174}
175
176impl ToPrompt for char {
177    fn to_prompt_parts(&self) -> Vec<PromptPart> {
178        vec![PromptPart::Text(self.to_string())]
179    }
180
181    fn to_prompt(&self) -> String {
182        self.to_string()
183    }
184}
185
186macro_rules! impl_to_prompt_for_numbers {
187    ($($t:ty),*) => {
188        $(
189            impl ToPrompt for $t {
190                fn to_prompt_parts(&self) -> Vec<PromptPart> {
191                    vec![PromptPart::Text(self.to_string())]
192                }
193
194                fn to_prompt(&self) -> String {
195                    self.to_string()
196                }
197            }
198        )*
199    };
200}
201
202impl_to_prompt_for_numbers!(
203    i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
204);
205
206// Implement ToPrompt for Vec<T> where T: ToPrompt
207impl<T: ToPrompt> ToPrompt for Vec<T> {
208    fn to_prompt_parts(&self) -> Vec<PromptPart> {
209        vec![PromptPart::Text(self.to_prompt())]
210    }
211
212    fn to_prompt(&self) -> String {
213        format!(
214            "[{}]",
215            self.iter()
216                .map(|item| item.to_prompt())
217                .collect::<Vec<_>>()
218                .join(", ")
219        )
220    }
221}
222
223/// Renders a prompt from a template string and a serializable context.
224///
225/// This is the underlying function for the `prompt!` macro.
226pub fn render_prompt<T: Serialize>(template: &str, context: T) -> Result<String, minijinja::Error> {
227    let mut env = Environment::new();
228    env.add_template("prompt", template)?;
229    let tmpl = env.get_template("prompt")?;
230    tmpl.render(context)
231}
232
233/// Creates a prompt string from a template and key-value pairs.
234///
235/// This macro provides a `println!`-like experience for building prompts
236/// from various data sources. It leverages `minijinja` for templating.
237///
238/// # Example
239///
240/// ```
241/// use llm_toolkit::prompt;
242/// use serde::Serialize;
243///
244/// #[derive(Serialize)]
245/// struct User {
246///     name: &'static str,
247///     role: &'static str,
248/// }
249///
250/// let user = User { name: "Mai", role: "UX Engineer" };
251/// let task = "designing a new macro";
252///
253/// let p = prompt!(
254///     "User {{user.name}} ({{user.role}}) is currently {{task}}.",
255///     user = user,
256///     task = task
257/// ).unwrap();
258///
259/// assert_eq!(p, "User Mai (UX Engineer) is currently designing a new macro.");
260/// ```
261#[macro_export]
262macro_rules! prompt {
263    ($template:expr, $($key:ident = $value:expr),* $(,)?) => {
264        $crate::prompt::render_prompt($template, minijinja::context!($($key => $value),*))
265    };
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use serde::Serialize;
272    use std::fmt::Display;
273
274    enum TestEnum {
275        VariantA,
276        VariantB,
277    }
278
279    impl Display for TestEnum {
280        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281            match self {
282                TestEnum::VariantA => write!(f, "Variant A"),
283                TestEnum::VariantB => write!(f, "Variant B"),
284            }
285        }
286    }
287
288    impl ToPrompt for TestEnum {
289        fn to_prompt_parts(&self) -> Vec<PromptPart> {
290            vec![PromptPart::Text(self.to_string())]
291        }
292
293        fn to_prompt(&self) -> String {
294            self.to_string()
295        }
296    }
297
298    #[test]
299    fn test_to_prompt_for_enum() {
300        let variant = TestEnum::VariantA;
301        assert_eq!(variant.to_prompt(), "Variant A");
302    }
303
304    #[test]
305    fn test_to_prompt_for_enum_variant_b() {
306        let variant = TestEnum::VariantB;
307        assert_eq!(variant.to_prompt(), "Variant B");
308    }
309
310    #[test]
311    fn test_to_prompt_for_string() {
312        let s = "hello world";
313        assert_eq!(s.to_prompt(), "hello world");
314    }
315
316    #[test]
317    fn test_to_prompt_for_number() {
318        let n = 42;
319        assert_eq!(n.to_prompt(), "42");
320    }
321
322    #[derive(Serialize)]
323    struct SystemInfo {
324        version: &'static str,
325        os: &'static str,
326    }
327
328    #[test]
329    fn test_prompt_macro_simple() {
330        let user = "Yui";
331        let task = "implementation";
332        let prompt = prompt!(
333            "User {{user}} is working on the {{task}}.",
334            user = user,
335            task = task
336        )
337        .unwrap();
338        assert_eq!(prompt, "User Yui is working on the implementation.");
339    }
340
341    #[test]
342    fn test_prompt_macro_with_struct() {
343        let sys = SystemInfo {
344            version: "0.1.0",
345            os: "Rust",
346        };
347        let prompt = prompt!("System: {{sys.version}} on {{sys.os}}", sys = sys).unwrap();
348        assert_eq!(prompt, "System: 0.1.0 on Rust");
349    }
350
351    #[test]
352    fn test_prompt_macro_mixed() {
353        let user = "Mai";
354        let sys = SystemInfo {
355            version: "0.1.0",
356            os: "Rust",
357        };
358        let prompt = prompt!(
359            "User {{user}} is using {{sys.os}} v{{sys.version}}.",
360            user = user,
361            sys = sys
362        )
363        .unwrap();
364        assert_eq!(prompt, "User Mai is using Rust v0.1.0.");
365    }
366
367    #[test]
368    fn test_to_prompt_for_vec_of_strings() {
369        let items = vec!["apple", "banana", "cherry"];
370        assert_eq!(items.to_prompt(), "[apple, banana, cherry]");
371    }
372
373    #[test]
374    fn test_to_prompt_for_vec_of_numbers() {
375        let numbers = vec![1, 2, 3, 42];
376        assert_eq!(numbers.to_prompt(), "[1, 2, 3, 42]");
377    }
378
379    #[test]
380    fn test_to_prompt_for_empty_vec() {
381        let empty: Vec<String> = vec![];
382        assert_eq!(empty.to_prompt(), "[]");
383    }
384
385    #[test]
386    fn test_to_prompt_for_nested_vec() {
387        let nested = vec![vec![1, 2], vec![3, 4]];
388        assert_eq!(nested.to_prompt(), "[[1, 2], [3, 4]]");
389    }
390
391    #[test]
392    fn test_to_prompt_parts_for_vec() {
393        let items = vec!["a", "b", "c"];
394        let parts = items.to_prompt_parts();
395        assert_eq!(parts.len(), 1);
396        match &parts[0] {
397            PromptPart::Text(text) => assert_eq!(text, "[a, b, c]"),
398            _ => panic!("Expected Text variant"),
399        }
400    }
401
402    #[test]
403    fn test_prompt_macro_no_args() {
404        let prompt = prompt!("This is a static prompt.",).unwrap();
405        assert_eq!(prompt, "This is a static prompt.");
406    }
407}
408
409#[derive(Debug, thiserror::Error)]
410pub enum PromptSetError {
411    #[error("Target '{target}' not found. Available targets: {available:?}")]
412    TargetNotFound {
413        target: String,
414        available: Vec<String>,
415    },
416    #[error("Failed to render prompt for target '{target}': {source}")]
417    RenderFailed {
418        target: String,
419        source: minijinja::Error,
420    },
421}
422
423/// A trait for types that can generate multiple named prompt targets.
424///
425/// This trait enables a single data structure to produce different prompt formats
426/// for various use cases (e.g., human-readable vs. machine-parsable formats).
427///
428/// # Example
429///
430/// ```ignore
431/// use llm_toolkit::prompt::{ToPromptSet, PromptPart};
432/// use serde::Serialize;
433///
434/// #[derive(ToPromptSet, Serialize)]
435/// #[prompt_for(name = "Visual", template = "## {{title}}\n\n> {{description}}")]
436/// struct Task {
437///     title: String,
438///     description: String,
439///
440///     #[prompt_for(name = "Agent")]
441///     priority: u8,
442///
443///     #[prompt_for(name = "Agent", rename = "internal_id")]
444///     id: u64,
445///
446///     #[prompt_for(skip)]
447///     is_dirty: bool,
448/// }
449///
450/// let task = Task {
451///     title: "Implement feature".to_string(),
452///     description: "Add new functionality".to_string(),
453///     priority: 1,
454///     id: 42,
455///     is_dirty: false,
456/// };
457///
458/// // Generate visual prompt
459/// let visual_prompt = task.to_prompt_for("Visual")?;
460///
461/// // Generate agent prompt
462/// let agent_prompt = task.to_prompt_for("Agent")?;
463/// ```
464pub trait ToPromptSet {
465    /// Generates multimodal prompt parts for the specified target.
466    fn to_prompt_parts_for(&self, target: &str) -> Result<Vec<PromptPart>, PromptSetError>;
467
468    /// Generates a text prompt for the specified target.
469    ///
470    /// This method extracts only the text portions from `to_prompt_parts_for()`
471    /// and joins them together.
472    fn to_prompt_for(&self, target: &str) -> Result<String, PromptSetError> {
473        let parts = self.to_prompt_parts_for(target)?;
474        let text = parts
475            .iter()
476            .filter_map(|part| match part {
477                PromptPart::Text(text) => Some(text.as_str()),
478                _ => None,
479            })
480            .collect::<Vec<_>>()
481            .join("\n");
482        Ok(text)
483    }
484}
485
486/// A trait for generating a prompt for a specific target type.
487///
488/// This allows a type (e.g., a `Tool`) to define how it should be represented
489/// in a prompt when provided with a target context (e.g., an `Agent`).
490pub trait ToPromptFor<T> {
491    /// Generates a prompt for the given target, using a specific mode.
492    fn to_prompt_for_with_mode(&self, target: &T, mode: &str) -> String;
493
494    /// Generates a prompt for the given target using the default "full" mode.
495    ///
496    /// This method provides backward compatibility by calling the `_with_mode`
497    /// variant with a default mode.
498    fn to_prompt_for(&self, target: &T) -> String {
499        self.to_prompt_for_with_mode(target, "full")
500    }
501}