Skip to main content

bob_core/
character.rs

1//! # Character / Persona Model
2//!
3//! Structured agent persona definitions that improve prompt engineering
4//! by separating identity, behavior guidelines, and knowledge from
5//! runtime plumbing.
6//!
7//! A [`Character`] bundles biography, style guidelines, topic expertise,
8//! and system-prompt template into a single serializable config artifact
9//! that can be loaded from TOML/JSON files or constructed in code.
10
11use serde::{Deserialize, Serialize};
12
13// ── Character ────────────────────────────────────────────────────────
14
15/// Structured agent persona.
16///
17/// Characters are loaded from configuration files (TOML/JSON) and
18/// injected into [`RequestContext`](crate::types::RequestContext)
19/// via [`to_system_prompt`](Self::to_system_prompt).
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct Character {
22    /// Display name (e.g. `"Bob"`, `"CodeReviewBot"`).
23    pub name: String,
24
25    /// Optional username / handle.
26    #[serde(default)]
27    pub username: Option<String>,
28
29    /// Short biography lines. Rendered as a bulleted list in the prompt.
30    #[serde(default)]
31    pub bio: Vec<String>,
32
33    /// Background lore or extended description.
34    #[serde(default)]
35    pub lore: Vec<String>,
36
37    /// Areas of expertise. Used for routing and capability matching.
38    #[serde(default)]
39    pub topics: Vec<String>,
40
41    /// Style guidelines applied to the assistant's responses.
42    #[serde(default)]
43    pub style: CharacterStyle,
44
45    /// Inline knowledge snippets injected into the system prompt.
46    #[serde(default)]
47    pub knowledge: Vec<String>,
48
49    /// Few-shot example conversations (user → assistant pairs).
50    #[serde(default)]
51    pub message_examples: Vec<MessageExample>,
52
53    /// Descriptive adjectives for the character.
54    #[serde(default)]
55    pub adjectives: Vec<String>,
56}
57
58// ── Style ────────────────────────────────────────────────────────────
59
60/// Style guidelines for response generation.
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct CharacterStyle {
63    /// General writing style rules (e.g. "Be concise", "Use technical language").
64    #[serde(default)]
65    pub general: Vec<String>,
66
67    /// Rules specific to chat responses.
68    #[serde(default)]
69    pub chat: Vec<String>,
70
71    /// Rules specific to long-form posts.
72    #[serde(default)]
73    pub post: Vec<String>,
74}
75
76// ── Message Example ──────────────────────────────────────────────────
77
78/// A single few-shot example pair.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct MessageExample {
81    /// User message.
82    pub user: String,
83    /// Expected assistant response.
84    pub assistant: String,
85}
86
87// ── Prompt Rendering ─────────────────────────────────────────────────
88
89impl Character {
90    /// Render the character into a system prompt string.
91    ///
92    /// The output follows a structured format suitable for injection
93    /// as the system message in an LLM conversation.
94    #[must_use]
95    pub fn to_system_prompt(&self) -> String {
96        let mut parts = Vec::new();
97
98        // Identity
99        if !self.name.is_empty() {
100            parts.push(format!("You are {}.", self.name));
101        }
102
103        // Bio
104        if !self.bio.is_empty() {
105            parts.push(String::new());
106            parts.push("About you:".to_string());
107            for line in &self.bio {
108                parts.push(format!("- {line}"));
109            }
110        }
111
112        // Lore
113        if !self.lore.is_empty() {
114            parts.push(String::new());
115            parts.push("Background:".to_string());
116            for line in &self.lore {
117                parts.push(format!("- {line}"));
118            }
119        }
120
121        // Topics
122        if !self.topics.is_empty() {
123            parts.push(String::new());
124            parts.push(format!("Your areas of expertise: {}", self.topics.join(", ")));
125        }
126
127        // Style
128        let has_style = !self.style.general.is_empty() ||
129            !self.style.chat.is_empty() ||
130            !self.style.post.is_empty();
131        if has_style {
132            parts.push(String::new());
133            parts.push("Style guidelines:".to_string());
134            for rule in &self.style.general {
135                parts.push(format!("- {rule}"));
136            }
137            for rule in &self.style.chat {
138                parts.push(format!("- [chat] {rule}"));
139            }
140            for rule in &self.style.post {
141                parts.push(format!("- [post] {rule}"));
142            }
143        }
144
145        // Adjectives
146        if !self.adjectives.is_empty() {
147            parts.push(String::new());
148            parts.push(format!("You are: {}", self.adjectives.join(", ")));
149        }
150
151        // Knowledge
152        if !self.knowledge.is_empty() {
153            parts.push(String::new());
154            parts.push("Reference knowledge:".to_string());
155            for snippet in &self.knowledge {
156                parts.push(format!("- {snippet}"));
157            }
158        }
159
160        parts.join("\n")
161    }
162
163    /// Returns `true` if the character has no meaningful content.
164    #[must_use]
165    pub fn is_empty(&self) -> bool {
166        self.name.is_empty() &&
167            self.bio.is_empty() &&
168            self.lore.is_empty() &&
169            self.topics.is_empty() &&
170            self.style.general.is_empty() &&
171            self.style.chat.is_empty() &&
172            self.style.post.is_empty() &&
173            self.knowledge.is_empty() &&
174            self.adjectives.is_empty()
175    }
176}
177
178// ── Tests ────────────────────────────────────────────────────────────
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn sample_character() -> Character {
185        Character {
186            name: "CodeBot".to_string(),
187            username: Some("codebot".to_string()),
188            bio: vec![
189                "An AI assistant specialized in code review.".to_string(),
190                "Focuses on Rust best practices.".to_string(),
191            ],
192            topics: vec!["rust".to_string(), "code-review".to_string()],
193            style: CharacterStyle {
194                general: vec!["Be concise and technical.".to_string()],
195                chat: vec!["Use code blocks for examples.".to_string()],
196                post: vec![],
197            },
198            adjectives: vec!["helpful".to_string(), "precise".to_string()],
199            knowledge: vec!["Always check for unwrap() in library code.".to_string()],
200            ..Default::default()
201        }
202    }
203
204    #[test]
205    fn renders_system_prompt() {
206        let character = sample_character();
207        let prompt = character.to_system_prompt();
208
209        assert!(prompt.contains("You are CodeBot."));
210        assert!(prompt.contains("About you:"));
211        assert!(prompt.contains("An AI assistant specialized in code review."));
212        assert!(prompt.contains("Your areas of expertise: rust, code-review"));
213        assert!(prompt.contains("Style guidelines:"));
214        assert!(prompt.contains("Be concise and technical."));
215        assert!(prompt.contains("[chat] Use code blocks for examples."));
216        assert!(prompt.contains("You are: helpful, precise"));
217        assert!(prompt.contains("Reference knowledge:"));
218        assert!(prompt.contains("Always check for unwrap() in library code."));
219    }
220
221    #[test]
222    fn empty_character_is_detected() {
223        let empty = Character::default();
224        assert!(empty.is_empty());
225
226        let with_name = Character { name: "Bot".to_string(), ..Default::default() };
227        assert!(!with_name.is_empty());
228    }
229
230    #[test]
231    fn serializes_to_json() {
232        let character = sample_character();
233        let json = serde_json::to_string(&character).expect("should serialize");
234        let roundtrip: Character = serde_json::from_str(&json).expect("should deserialize");
235        assert_eq!(roundtrip.name, "CodeBot");
236        assert_eq!(roundtrip.topics.len(), 2);
237    }
238
239    #[test]
240    fn partial_fields_default() {
241        let json = r#"{"name": "Test"}"#;
242        let character: Character = serde_json::from_str(json).expect("should deserialize");
243        assert!(character.bio.is_empty());
244        assert!(character.topics.is_empty());
245        assert!(character.style.general.is_empty());
246    }
247}