1use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::delta::{DeltaBatch, DeltaOperation, DeltaOperationType};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Bullet {
14 pub id: String,
15 pub section: String,
16 pub content: String,
17 #[serde(default)]
18 pub helpful: i64,
19 #[serde(default)]
20 pub harmful: i64,
21 #[serde(default)]
22 pub neutral: i64,
23 pub created_at: String,
24 pub updated_at: String,
25}
26
27impl Bullet {
28 pub fn apply_metadata(&mut self, metadata: &HashMap<String, i64>) {
30 if let Some(&v) = metadata.get("helpful") {
31 self.helpful = v;
32 }
33 if let Some(&v) = metadata.get("harmful") {
34 self.harmful = v;
35 }
36 if let Some(&v) = metadata.get("neutral") {
37 self.neutral = v;
38 }
39 }
40
41 pub fn tag(&mut self, tag: &str, increment: i64) -> Result<(), String> {
43 match tag {
44 "helpful" => self.helpful += increment,
45 "harmful" => self.harmful += increment,
46 "neutral" => self.neutral += increment,
47 _ => return Err(format!("Unsupported tag: {tag}")),
48 }
49 self.updated_at = Utc::now().to_rfc3339();
50 Ok(())
51 }
52}
53
54#[derive(Debug, Clone)]
59pub struct Playbook {
60 bullets: HashMap<String, Bullet>,
61 sections: HashMap<String, Vec<String>>,
62 next_id: u64,
63}
64
65impl Playbook {
66 pub fn new() -> Self {
68 Self {
69 bullets: HashMap::new(),
70 sections: HashMap::new(),
71 next_id: 0,
72 }
73 }
74
75 pub fn add_bullet(
77 &mut self,
78 section: &str,
79 content: &str,
80 bullet_id: Option<&str>,
81 metadata: Option<&HashMap<String, i64>>,
82 ) -> &Bullet {
83 let id = bullet_id
84 .map(String::from)
85 .unwrap_or_else(|| self.generate_id(section));
86 let now = Utc::now().to_rfc3339();
87 let mut bullet = Bullet {
88 id: id.clone(),
89 section: section.to_string(),
90 content: content.to_string(),
91 helpful: 0,
92 harmful: 0,
93 neutral: 0,
94 created_at: now.clone(),
95 updated_at: now,
96 };
97 if let Some(meta) = metadata {
98 bullet.apply_metadata(meta);
99 }
100 self.bullets.insert(id.clone(), bullet);
101 self.sections
102 .entry(section.to_string())
103 .or_default()
104 .push(id.clone());
105 self.bullets.get(&id).unwrap()
106 }
107
108 pub fn update_bullet(
110 &mut self,
111 bullet_id: &str,
112 content: Option<&str>,
113 metadata: Option<&HashMap<String, i64>>,
114 ) -> Option<&Bullet> {
115 let bullet = self.bullets.get_mut(bullet_id)?;
116 if let Some(c) = content {
117 bullet.content = c.to_string();
118 }
119 if let Some(meta) = metadata {
120 bullet.apply_metadata(meta);
121 }
122 bullet.updated_at = Utc::now().to_rfc3339();
123 self.bullets.get(bullet_id)
124 }
125
126 pub fn tag_bullet(&mut self, bullet_id: &str, tag: &str, increment: i64) -> Option<&Bullet> {
128 let bullet = self.bullets.get_mut(bullet_id)?;
129 let _ = bullet.tag(tag, increment);
130 self.bullets.get(bullet_id)
131 }
132
133 pub fn remove_bullet(&mut self, bullet_id: &str) {
135 if let Some(bullet) = self.bullets.remove(bullet_id)
136 && let Some(section_ids) = self.sections.get_mut(&bullet.section)
137 {
138 section_ids.retain(|id| id != bullet_id);
139 if section_ids.is_empty() {
140 self.sections.remove(&bullet.section);
141 }
142 }
143 }
144
145 pub fn get_bullet(&self, bullet_id: &str) -> Option<&Bullet> {
147 self.bullets.get(bullet_id)
148 }
149
150 pub fn bullets(&self) -> Vec<&Bullet> {
152 self.bullets.values().collect()
153 }
154
155 pub fn bullet_count(&self) -> usize {
157 self.bullets.len()
158 }
159
160 pub fn section_names(&self) -> Vec<&str> {
162 self.sections.keys().map(String::as_str).collect()
163 }
164
165 pub fn to_dict(&self) -> serde_json::Value {
171 let bullets_map: serde_json::Map<String, serde_json::Value> = self
172 .bullets
173 .iter()
174 .map(|(id, bullet)| (id.clone(), serde_json::to_value(bullet).unwrap_or_default()))
175 .collect();
176 serde_json::json!({
177 "bullets": bullets_map,
178 "sections": self.sections,
179 "next_id": self.next_id,
180 })
181 }
182
183 pub fn from_dict(payload: &serde_json::Value) -> Self {
185 let mut instance = Self::new();
186
187 if let Some(bullets_obj) = payload.get("bullets").and_then(|v| v.as_object()) {
188 for (id, val) in bullets_obj {
189 if let Ok(bullet) = serde_json::from_value::<Bullet>(val.clone()) {
190 instance.bullets.insert(id.clone(), bullet);
191 }
192 }
193 }
194
195 if let Some(sections_obj) = payload.get("sections").and_then(|v| v.as_object()) {
196 for (section, ids_val) in sections_obj {
197 if let Some(ids_arr) = ids_val.as_array() {
198 let ids: Vec<String> = ids_arr
199 .iter()
200 .filter_map(|v| v.as_str().map(String::from))
201 .collect();
202 instance.sections.insert(section.clone(), ids);
203 }
204 }
205 }
206
207 instance.next_id = payload.get("next_id").and_then(|v| v.as_u64()).unwrap_or(0);
208
209 instance
210 }
211
212 pub fn dumps(&self) -> String {
214 serde_json::to_string_pretty(&self.to_dict()).unwrap_or_default()
215 }
216
217 pub fn loads(data: &str) -> Result<Self, serde_json::Error> {
219 let payload: serde_json::Value = serde_json::from_str(data)?;
220 Ok(Self::from_dict(&payload))
221 }
222
223 pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
225 if let Some(parent) = path.parent() {
226 std::fs::create_dir_all(parent)?;
227 }
228 std::fs::write(path, self.dumps())
229 }
230
231 pub fn load_from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
233 let content = std::fs::read_to_string(path)?;
234 Ok(Self::loads(&content)?)
235 }
236
237 pub fn apply_delta(&mut self, delta: &DeltaBatch) {
243 for operation in &delta.operations {
244 self.apply_operation(operation);
245 }
246 }
247
248 fn apply_operation(&mut self, operation: &DeltaOperation) {
250 match operation.op_type {
251 DeltaOperationType::Add => {
252 self.add_bullet(
253 &operation.section,
254 operation.content.as_deref().unwrap_or(""),
255 operation.bullet_id.as_deref(),
256 if operation.metadata.is_empty() {
257 None
258 } else {
259 Some(&operation.metadata)
260 },
261 );
262 }
263 DeltaOperationType::Update => {
264 if let Some(ref bid) = operation.bullet_id {
265 self.update_bullet(
266 bid,
267 operation.content.as_deref(),
268 if operation.metadata.is_empty() {
269 None
270 } else {
271 Some(&operation.metadata)
272 },
273 );
274 }
275 }
276 DeltaOperationType::Tag => {
277 if let Some(ref bid) = operation.bullet_id {
278 let valid_tags = ["helpful", "harmful", "neutral"];
279 for (tag, &increment) in &operation.metadata {
280 if valid_tags.contains(&tag.as_str()) {
281 self.tag_bullet(bid, tag, increment);
282 }
283 }
284 }
285 }
286 DeltaOperationType::Remove => {
287 if let Some(ref bid) = operation.bullet_id {
288 self.remove_bullet(bid);
289 }
290 }
291 }
292 }
293
294 pub fn as_prompt(&self) -> String {
300 if self.bullets.is_empty() {
301 return String::new();
302 }
303 let mut parts = Vec::new();
304 let mut sorted_sections: Vec<_> = self.sections.iter().collect();
305 sorted_sections.sort_by_key(|(name, _)| *name);
306
307 for (section, bullet_ids) in sorted_sections {
308 parts.push(format!("## {section}"));
309 for bid in bullet_ids {
310 if let Some(bullet) = self.bullets.get(bid) {
311 let counters = format!(
312 "(helpful={}, harmful={}, neutral={})",
313 bullet.helpful, bullet.harmful, bullet.neutral
314 );
315 parts.push(format!("- [{}] {} {}", bullet.id, bullet.content, counters));
316 }
317 }
318 }
319 parts.join("\n")
320 }
321
322 pub fn stats(&self) -> PlaybookStats {
324 let mut helpful = 0i64;
325 let mut harmful = 0i64;
326 let mut neutral = 0i64;
327 for bullet in self.bullets.values() {
328 helpful += bullet.helpful;
329 harmful += bullet.harmful;
330 neutral += bullet.neutral;
331 }
332 PlaybookStats {
333 sections: self.sections.len(),
334 bullets: self.bullets.len(),
335 helpful,
336 harmful,
337 neutral,
338 }
339 }
340
341 fn generate_id(&mut self, section: &str) -> String {
346 self.next_id += 1;
347 let prefix = section
348 .split_whitespace()
349 .next()
350 .unwrap_or("bullet")
351 .to_lowercase();
352 format!("{prefix}-{:05}", self.next_id)
353 }
354}
355
356impl Default for Playbook {
357 fn default() -> Self {
358 Self::new()
359 }
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PlaybookStats {
365 pub sections: usize,
366 pub bullets: usize,
367 pub helpful: i64,
368 pub harmful: i64,
369 pub neutral: i64,
370}
371
372#[cfg(test)]
373#[path = "playbook_tests.rs"]
374mod tests;