Skip to main content

opendev_memory/
delta.rs

1//! Delta batch operations for playbook mutations.
2//!
3//! Mirrors `opendev/core/context_engineering/memory/delta.py`.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fmt;
8
9/// Type of delta operation.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "UPPERCASE")]
12pub enum DeltaOperationType {
13    Add,
14    Update,
15    Tag,
16    Remove,
17}
18
19impl fmt::Display for DeltaOperationType {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::Add => write!(f, "ADD"),
23            Self::Update => write!(f, "UPDATE"),
24            Self::Tag => write!(f, "TAG"),
25            Self::Remove => write!(f, "REMOVE"),
26        }
27    }
28}
29
30/// Single mutation to apply to the playbook.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DeltaOperation {
33    #[serde(rename = "type")]
34    pub op_type: DeltaOperationType,
35    pub section: String,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub content: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub bullet_id: Option<String>,
40    #[serde(default)]
41    pub metadata: HashMap<String, i64>,
42}
43
44impl DeltaOperation {
45    /// Create a DeltaOperation from a JSON value.
46    pub fn from_json(payload: &serde_json::Value) -> Option<Self> {
47        let op_type_str = payload["type"].as_str()?.to_uppercase();
48        let op_type = match op_type_str.as_str() {
49            "ADD" => DeltaOperationType::Add,
50            "UPDATE" => DeltaOperationType::Update,
51            "TAG" => DeltaOperationType::Tag,
52            "REMOVE" => DeltaOperationType::Remove,
53            _ => return None,
54        };
55
56        let section = payload
57            .get("section")
58            .and_then(|v| v.as_str())
59            .unwrap_or("")
60            .to_string();
61
62        let content = payload
63            .get("content")
64            .and_then(|v| v.as_str())
65            .map(String::from);
66
67        let bullet_id = payload
68            .get("bullet_id")
69            .and_then(|v| v.as_str())
70            .map(String::from);
71
72        let mut metadata = HashMap::new();
73        if let Some(meta_obj) = payload.get("metadata").and_then(|v| v.as_object()) {
74            let valid_tags: &[&str] = if op_type == DeltaOperationType::Tag {
75                &["helpful", "harmful", "neutral"]
76            } else {
77                // For non-TAG operations, accept all keys
78                &[]
79            };
80
81            for (k, v) in meta_obj {
82                if op_type == DeltaOperationType::Tag && !valid_tags.contains(&k.as_str()) {
83                    continue;
84                }
85                if let Some(n) = v.as_i64() {
86                    metadata.insert(k.clone(), n);
87                }
88            }
89        }
90
91        Some(Self {
92            op_type,
93            section,
94            content,
95            bullet_id,
96            metadata,
97        })
98    }
99
100    /// Convert to JSON value.
101    pub fn to_json(&self) -> serde_json::Value {
102        let mut data = serde_json::json!({
103            "type": self.op_type,
104            "section": self.section,
105        });
106        if let Some(ref content) = self.content {
107            data["content"] = serde_json::Value::String(content.clone());
108        }
109        if let Some(ref bullet_id) = self.bullet_id {
110            data["bullet_id"] = serde_json::Value::String(bullet_id.clone());
111        }
112        if !self.metadata.is_empty() {
113            data["metadata"] = serde_json::to_value(&self.metadata).unwrap_or_default();
114        }
115        data
116    }
117}
118
119/// Bundle of curator reasoning and operations.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct DeltaBatch {
122    pub reasoning: String,
123    #[serde(default)]
124    pub operations: Vec<DeltaOperation>,
125}
126
127impl DeltaBatch {
128    /// Create a DeltaBatch from a JSON value.
129    pub fn from_json(payload: &serde_json::Value) -> Self {
130        let reasoning = payload
131            .get("reasoning")
132            .and_then(|v| v.as_str())
133            .unwrap_or("")
134            .to_string();
135
136        let mut operations = Vec::new();
137        if let Some(ops_array) = payload.get("operations").and_then(|v| v.as_array()) {
138            for item in ops_array {
139                if let Some(op) = DeltaOperation::from_json(item) {
140                    operations.push(op);
141                }
142            }
143        }
144
145        Self {
146            reasoning,
147            operations,
148        }
149    }
150
151    /// Convert to JSON value.
152    pub fn to_json(&self) -> serde_json::Value {
153        serde_json::json!({
154            "reasoning": self.reasoning,
155            "operations": self.operations.iter().map(|op| op.to_json()).collect::<Vec<_>>(),
156        })
157    }
158}
159
160#[cfg(test)]
161#[path = "delta_tests.rs"]
162mod tests;