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 #[test]
409 fn test_render_prompt_with_json_value_dot_notation() {
410 use serde_json::json;
411
412 let context = json!({
413 "user": {
414 "name": "Alice",
415 "age": 30,
416 "profile": {
417 "role": "Developer"
418 }
419 }
420 });
421
422 let template =
423 "{{ user.name }} is {{ user.age }} years old and works as {{ user.profile.role }}";
424 let result = render_prompt(template, &context).unwrap();
425
426 assert_eq!(result, "Alice is 30 years old and works as Developer");
427 }
428
429 #[test]
430 fn test_render_prompt_with_hashmap_json_value() {
431 use serde_json::json;
432 use std::collections::HashMap;
433
434 let mut context = HashMap::new();
435 context.insert(
436 "step_1_output".to_string(),
437 json!({
438 "result": "success",
439 "data": {
440 "count": 42
441 }
442 }),
443 );
444 context.insert("task".to_string(), json!("analysis"));
445
446 let template = "Task: {{ task }}, Result: {{ step_1_output.result }}, Count: {{ step_1_output.data.count }}";
447 let result = render_prompt(template, &context).unwrap();
448
449 assert_eq!(result, "Task: analysis, Result: success, Count: 42");
450 }
451}
452
453#[derive(Debug, thiserror::Error)]
454pub enum PromptSetError {
455 #[error("Target '{target}' not found. Available targets: {available:?}")]
456 TargetNotFound {
457 target: String,
458 available: Vec<String>,
459 },
460 #[error("Failed to render prompt for target '{target}': {source}")]
461 RenderFailed {
462 target: String,
463 source: minijinja::Error,
464 },
465}
466
467/// A trait for types that can generate multiple named prompt targets.
468///
469/// This trait enables a single data structure to produce different prompt formats
470/// for various use cases (e.g., human-readable vs. machine-parsable formats).
471///
472/// # Example
473///
474/// ```ignore
475/// use llm_toolkit::prompt::{ToPromptSet, PromptPart};
476/// use serde::Serialize;
477///
478/// #[derive(ToPromptSet, Serialize)]
479/// #[prompt_for(name = "Visual", template = "## {{title}}\n\n> {{description}}")]
480/// struct Task {
481/// title: String,
482/// description: String,
483///
484/// #[prompt_for(name = "Agent")]
485/// priority: u8,
486///
487/// #[prompt_for(name = "Agent", rename = "internal_id")]
488/// id: u64,
489///
490/// #[prompt_for(skip)]
491/// is_dirty: bool,
492/// }
493///
494/// let task = Task {
495/// title: "Implement feature".to_string(),
496/// description: "Add new functionality".to_string(),
497/// priority: 1,
498/// id: 42,
499/// is_dirty: false,
500/// };
501///
502/// // Generate visual prompt
503/// let visual_prompt = task.to_prompt_for("Visual")?;
504///
505/// // Generate agent prompt
506/// let agent_prompt = task.to_prompt_for("Agent")?;
507/// ```
508pub trait ToPromptSet {
509 /// Generates multimodal prompt parts for the specified target.
510 fn to_prompt_parts_for(&self, target: &str) -> Result<Vec<PromptPart>, PromptSetError>;
511
512 /// Generates a text prompt for the specified target.
513 ///
514 /// This method extracts only the text portions from `to_prompt_parts_for()`
515 /// and joins them together.
516 fn to_prompt_for(&self, target: &str) -> Result<String, PromptSetError> {
517 let parts = self.to_prompt_parts_for(target)?;
518 let text = parts
519 .iter()
520 .filter_map(|part| match part {
521 PromptPart::Text(text) => Some(text.as_str()),
522 _ => None,
523 })
524 .collect::<Vec<_>>()
525 .join("\n");
526 Ok(text)
527 }
528}
529
530/// A trait for generating a prompt for a specific target type.
531///
532/// This allows a type (e.g., a `Tool`) to define how it should be represented
533/// in a prompt when provided with a target context (e.g., an `Agent`).
534pub trait ToPromptFor<T> {
535 /// Generates a prompt for the given target, using a specific mode.
536 fn to_prompt_for_with_mode(&self, target: &T, mode: &str) -> String;
537
538 /// Generates a prompt for the given target using the default "full" mode.
539 ///
540 /// This method provides backward compatibility by calling the `_with_mode`
541 /// variant with a default mode.
542 fn to_prompt_for(&self, target: &T) -> String {
543 self.to_prompt_for_with_mode(target, "full")
544 }
545}