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.
76 ///
77 /// This method enables multimodal prompt generation by returning
78 /// a collection of prompt parts that can include text, images, and
79 /// other media types.
80 fn to_prompt_parts(&self) -> Vec<PromptPart>;
81
82 /// Converts the object into a prompt string.
83 ///
84 /// This method provides backward compatibility by extracting only
85 /// the text portions from `to_prompt_parts()` and joining them.
86 fn to_prompt(&self) -> String {
87 self.to_prompt_parts()
88 .iter()
89 .filter_map(|part| match part {
90 PromptPart::Text(text) => Some(text.as_str()),
91 _ => None,
92 })
93 .collect::<Vec<_>>()
94 .join("")
95 }
96}
97
98// Add implementations for common types
99
100impl ToPrompt for String {
101 fn to_prompt_parts(&self) -> Vec<PromptPart> {
102 vec![PromptPart::Text(self.clone())]
103 }
104
105 fn to_prompt(&self) -> String {
106 self.clone()
107 }
108}
109
110impl ToPrompt for &str {
111 fn to_prompt_parts(&self) -> Vec<PromptPart> {
112 vec![PromptPart::Text(self.to_string())]
113 }
114
115 fn to_prompt(&self) -> String {
116 self.to_string()
117 }
118}
119
120impl ToPrompt for bool {
121 fn to_prompt_parts(&self) -> Vec<PromptPart> {
122 vec![PromptPart::Text(self.to_string())]
123 }
124
125 fn to_prompt(&self) -> String {
126 self.to_string()
127 }
128}
129
130impl ToPrompt for char {
131 fn to_prompt_parts(&self) -> Vec<PromptPart> {
132 vec![PromptPart::Text(self.to_string())]
133 }
134
135 fn to_prompt(&self) -> String {
136 self.to_string()
137 }
138}
139
140macro_rules! impl_to_prompt_for_numbers {
141 ($($t:ty),*) => {
142 $(
143 impl ToPrompt for $t {
144 fn to_prompt_parts(&self) -> Vec<PromptPart> {
145 vec![PromptPart::Text(self.to_string())]
146 }
147
148 fn to_prompt(&self) -> String {
149 self.to_string()
150 }
151 }
152 )*
153 };
154}
155
156impl_to_prompt_for_numbers!(
157 i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
158);
159
160/// Renders a prompt from a template string and a serializable context.
161///
162/// This is the underlying function for the `prompt!` macro.
163pub fn render_prompt<T: Serialize>(template: &str, context: T) -> Result<String, minijinja::Error> {
164 let mut env = Environment::new();
165 env.add_template("prompt", template)?;
166 let tmpl = env.get_template("prompt")?;
167 tmpl.render(context)
168}
169
170/// Creates a prompt string from a template and key-value pairs.
171///
172/// This macro provides a `println!`-like experience for building prompts
173/// from various data sources. It leverages `minijinja` for templating.
174///
175/// # Example
176///
177/// ```
178/// use llm_toolkit::prompt;
179/// use serde::Serialize;
180///
181/// #[derive(Serialize)]
182/// struct User {
183/// name: &'static str,
184/// role: &'static str,
185/// }
186///
187/// let user = User { name: "Mai", role: "UX Engineer" };
188/// let task = "designing a new macro";
189///
190/// let p = prompt!(
191/// "User {{user.name}} ({{user.role}}) is currently {{task}}.",
192/// user = user,
193/// task = task
194/// ).unwrap();
195///
196/// assert_eq!(p, "User Mai (UX Engineer) is currently designing a new macro.");
197/// ```
198#[macro_export]
199macro_rules! prompt {
200 ($template:expr, $($key:ident = $value:expr),* $(,)?) => {
201 $crate::prompt::render_prompt($template, minijinja::context!($($key => $value),*))
202 };
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use serde::Serialize;
209 use std::fmt::Display;
210
211 enum TestEnum {
212 VariantA,
213 VariantB,
214 }
215
216 impl Display for TestEnum {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 match self {
219 TestEnum::VariantA => write!(f, "Variant A"),
220 TestEnum::VariantB => write!(f, "Variant B"),
221 }
222 }
223 }
224
225 impl ToPrompt for TestEnum {
226 fn to_prompt_parts(&self) -> Vec<PromptPart> {
227 vec![PromptPart::Text(self.to_string())]
228 }
229
230 fn to_prompt(&self) -> String {
231 self.to_string()
232 }
233 }
234
235 #[test]
236 fn test_to_prompt_for_enum() {
237 let variant = TestEnum::VariantA;
238 assert_eq!(variant.to_prompt(), "Variant A");
239 }
240
241 #[test]
242 fn test_to_prompt_for_enum_variant_b() {
243 let variant = TestEnum::VariantB;
244 assert_eq!(variant.to_prompt(), "Variant B");
245 }
246
247 #[test]
248 fn test_to_prompt_for_string() {
249 let s = "hello world";
250 assert_eq!(s.to_prompt(), "hello world");
251 }
252
253 #[test]
254 fn test_to_prompt_for_number() {
255 let n = 42;
256 assert_eq!(n.to_prompt(), "42");
257 }
258
259 #[derive(Serialize)]
260 struct SystemInfo {
261 version: &'static str,
262 os: &'static str,
263 }
264
265 #[test]
266 fn test_prompt_macro_simple() {
267 let user = "Yui";
268 let task = "implementation";
269 let prompt = prompt!(
270 "User {{user}} is working on the {{task}}.",
271 user = user,
272 task = task
273 )
274 .unwrap();
275 assert_eq!(prompt, "User Yui is working on the implementation.");
276 }
277
278 #[test]
279 fn test_prompt_macro_with_struct() {
280 let sys = SystemInfo {
281 version: "0.1.0",
282 os: "Rust",
283 };
284 let prompt = prompt!("System: {{sys.version}} on {{sys.os}}", sys = sys).unwrap();
285 assert_eq!(prompt, "System: 0.1.0 on Rust");
286 }
287
288 #[test]
289 fn test_prompt_macro_mixed() {
290 let user = "Mai";
291 let sys = SystemInfo {
292 version: "0.1.0",
293 os: "Rust",
294 };
295 let prompt = prompt!(
296 "User {{user}} is using {{sys.os}} v{{sys.version}}.",
297 user = user,
298 sys = sys
299 )
300 .unwrap();
301 assert_eq!(prompt, "User Mai is using Rust v0.1.0.");
302 }
303
304 #[test]
305 fn test_prompt_macro_no_args() {
306 let prompt = prompt!("This is a static prompt.",).unwrap();
307 assert_eq!(prompt, "This is a static prompt.");
308 }
309}
310
311#[derive(Debug, thiserror::Error)]
312pub enum PromptSetError {
313 #[error("Target '{target}' not found. Available targets: {available:?}")]
314 TargetNotFound {
315 target: String,
316 available: Vec<String>,
317 },
318 #[error("Failed to render prompt for target '{target}': {source}")]
319 RenderFailed {
320 target: String,
321 source: minijinja::Error,
322 },
323}
324
325/// A trait for types that can generate multiple named prompt targets.
326///
327/// This trait enables a single data structure to produce different prompt formats
328/// for various use cases (e.g., human-readable vs. machine-parsable formats).
329///
330/// # Example
331///
332/// ```ignore
333/// use llm_toolkit::prompt::{ToPromptSet, PromptPart};
334/// use serde::Serialize;
335///
336/// #[derive(ToPromptSet, Serialize)]
337/// #[prompt_for(name = "Visual", template = "## {{title}}\n\n> {{description}}")]
338/// struct Task {
339/// title: String,
340/// description: String,
341///
342/// #[prompt_for(name = "Agent")]
343/// priority: u8,
344///
345/// #[prompt_for(name = "Agent", rename = "internal_id")]
346/// id: u64,
347///
348/// #[prompt_for(skip)]
349/// is_dirty: bool,
350/// }
351///
352/// let task = Task {
353/// title: "Implement feature".to_string(),
354/// description: "Add new functionality".to_string(),
355/// priority: 1,
356/// id: 42,
357/// is_dirty: false,
358/// };
359///
360/// // Generate visual prompt
361/// let visual_prompt = task.to_prompt_for("Visual")?;
362///
363/// // Generate agent prompt
364/// let agent_prompt = task.to_prompt_for("Agent")?;
365/// ```
366pub trait ToPromptSet {
367 /// Generates multimodal prompt parts for the specified target.
368 fn to_prompt_parts_for(&self, target: &str) -> Result<Vec<PromptPart>, PromptSetError>;
369
370 /// Generates a text prompt for the specified target.
371 ///
372 /// This method extracts only the text portions from `to_prompt_parts_for()`
373 /// and joins them together.
374 fn to_prompt_for(&self, target: &str) -> Result<String, PromptSetError> {
375 let parts = self.to_prompt_parts_for(target)?;
376 let text = parts
377 .iter()
378 .filter_map(|part| match part {
379 PromptPart::Text(text) => Some(text.as_str()),
380 _ => None,
381 })
382 .collect::<Vec<_>>()
383 .join("\n");
384 Ok(text)
385 }
386}