mempal_runtime/ingest/
normalize.rs1use serde_json::Value;
2use thiserror::Error;
3
4use super::detect::{Format, extract_content_text, extract_message_text};
5use super::noise::{strip_claude_jsonl_noise, strip_codex_rollout_noise};
6
7pub const CURRENT_NORMALIZE_VERSION: u32 = 2;
8
9pub type Result<T> = std::result::Result<T, NormalizeError>;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct NormalizeOptions {
13 pub strip_noise: bool,
14}
15
16impl Default for NormalizeOptions {
17 fn default() -> Self {
18 Self { strip_noise: true }
19 }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct NormalizeOutput {
24 pub content: String,
25 pub noise_bytes_stripped: Option<u64>,
26}
27
28#[derive(Debug, Error)]
29pub enum NormalizeError {
30 #[error(transparent)]
31 Json(#[from] serde_json::Error),
32 #[error("unsupported ChatGPT JSON shape")]
33 UnsupportedChatGptShape,
34}
35
36pub fn normalize_content(content: &str, format: Format) -> Result<String> {
37 Ok(normalize_content_with_options(content, format, NormalizeOptions::default())?.content)
38}
39
40pub fn normalize_content_with_options(
41 content: &str,
42 format: Format,
43 options: NormalizeOptions,
44) -> Result<NormalizeOutput> {
45 match format {
46 Format::PlainText => Ok(NormalizeOutput {
47 content: content.trim().to_string(),
48 noise_bytes_stripped: None,
49 }),
50 Format::ClaudeJsonl => normalize_claude_jsonl(content, options.strip_noise),
51 Format::ChatGptJson => Ok(NormalizeOutput {
52 content: normalize_chatgpt_json(content)?,
53 noise_bytes_stripped: None,
54 }),
55 Format::CodexJsonl => normalize_codex_jsonl(content, options.strip_noise),
56 Format::SlackJson => Ok(NormalizeOutput {
57 content: normalize_slack_json(content)?,
58 noise_bytes_stripped: None,
59 }),
60 }
61}
62
63fn normalize_claude_jsonl(content: &str, strip_noise: bool) -> Result<NormalizeOutput> {
64 let mut lines = Vec::new();
65 let mut noise_bytes_stripped = 0_u64;
66
67 for raw_line in content
68 .lines()
69 .map(str::trim)
70 .filter(|line| !line.is_empty())
71 {
72 let value: Value = serde_json::from_str(raw_line)?;
73 let role = value
74 .get("type")
75 .and_then(Value::as_str)
76 .unwrap_or("assistant");
77 let message = extract_message_text(&value).unwrap_or_default();
78 let message = message.trim();
79 let message = if strip_noise {
80 let stripped = strip_claude_jsonl_noise(message);
81 noise_bytes_stripped += message.len().saturating_sub(stripped.len()) as u64;
82 stripped
83 } else {
84 message.to_string()
85 };
86
87 if message.trim().is_empty() {
88 continue;
89 }
90
91 if matches!(role, "human" | "user") {
92 lines.push(format!("> {}", message.trim()));
93 } else {
94 lines.push(message.trim().to_string());
95 }
96 }
97
98 Ok(NormalizeOutput {
99 content: lines.join("\n"),
100 noise_bytes_stripped: strip_noise.then_some(noise_bytes_stripped),
101 })
102}
103
104fn normalize_chatgpt_json(content: &str) -> Result<String> {
105 let value: Value = serde_json::from_str(content)?;
106
107 if let Some(messages) = value.as_array() {
108 return normalize_chatgpt_messages(messages);
109 }
110
111 if let Some(messages) = value.get("messages").and_then(Value::as_array) {
112 return normalize_chatgpt_messages(messages);
113 }
114
115 if let Some(mapping) = value.get("mapping").and_then(Value::as_object) {
116 let mut ordered = Vec::new();
117 if let Some(root_id) = find_root_node(mapping) {
118 collect_messages_dfs(mapping, &root_id, &mut ordered);
119 }
120
121 return Ok(render_transcript(ordered));
122 }
123
124 Err(NormalizeError::UnsupportedChatGptShape)
125}
126
127fn normalize_chatgpt_messages(messages: &[Value]) -> Result<String> {
128 let transcript = render_transcript(messages.iter().filter_map(|message| {
129 let role = message.get("role").and_then(Value::as_str)?;
130 let content = message.get("content").and_then(extract_content_text)?;
131 Some((role.to_string(), content))
132 }));
133
134 Ok(transcript)
135}
136
137fn find_root_node(mapping: &serde_json::Map<String, Value>) -> Option<String> {
138 mapping
139 .iter()
140 .find(|(_, node)| {
141 node.get("parent")
142 .is_none_or(|p| p.is_null() || p.as_str() == Some(""))
143 })
144 .map(|(id, _)| id.clone())
145}
146
147fn collect_messages_dfs(
148 mapping: &serde_json::Map<String, Value>,
149 node_id: &str,
150 result: &mut Vec<(String, String)>,
151) {
152 let Some(node) = mapping.get(node_id) else {
153 return;
154 };
155
156 if let Some(message) = node.get("message") {
157 let role = message
158 .get("author")
159 .and_then(|author| author.get("role"))
160 .and_then(Value::as_str);
161 let content = message.get("content").and_then(extract_content_text);
162 if let (Some(role), Some(content)) = (role, content) {
163 result.push((role.to_string(), content));
164 }
165 }
166
167 if let Some(children) = node.get("children").and_then(Value::as_array) {
168 for child in children {
169 if let Some(child_id) = child.as_str() {
170 collect_messages_dfs(mapping, child_id, result);
171 }
172 }
173 }
174}
175
176fn normalize_codex_jsonl(content: &str, strip_noise: bool) -> Result<NormalizeOutput> {
177 let mut pairs: Vec<(String, String)> = Vec::new();
178 let mut noise_bytes_stripped = 0_u64;
179
180 for line in content.lines().map(str::trim).filter(|l| !l.is_empty()) {
181 let value: Value = serde_json::from_str(line)?;
182 if value.get("type").and_then(Value::as_str) != Some("event_msg") {
183 continue;
184 }
185 let Some(payload) = value.get("payload") else {
186 continue;
187 };
188 let msg_type = payload.get("type").and_then(Value::as_str).unwrap_or("");
189 let message = payload
190 .get("message")
191 .and_then(Value::as_str)
192 .unwrap_or("")
193 .trim();
194 let message = if strip_noise {
195 let stripped = strip_codex_rollout_noise(message);
196 noise_bytes_stripped += message.len().saturating_sub(stripped.len()) as u64;
197 stripped
198 } else {
199 message.to_string()
200 };
201 if message.is_empty() {
202 continue;
203 }
204 match msg_type {
205 "user_message" => pairs.push(("user".to_string(), message)),
206 "agent_message" => pairs.push(("assistant".to_string(), message)),
207 _ => {}
208 }
209 }
210
211 Ok(NormalizeOutput {
212 content: render_transcript(pairs),
213 noise_bytes_stripped: strip_noise.then_some(noise_bytes_stripped),
214 })
215}
216
217fn normalize_slack_json(content: &str) -> Result<String> {
218 let value: Value = serde_json::from_str(content)?;
219 let messages = value
220 .as_array()
221 .ok_or(NormalizeError::UnsupportedChatGptShape)?;
222
223 let mut speakers: Vec<String> = Vec::new();
224 let mut pairs: Vec<(String, String)> = Vec::new();
225
226 for msg in messages {
227 if msg.get("type").and_then(Value::as_str) != Some("message") {
228 continue;
229 }
230 let speaker = msg
231 .get("user")
232 .or_else(|| msg.get("username"))
233 .and_then(Value::as_str)
234 .unwrap_or("unknown")
235 .to_string();
236 let text = msg.get("text").and_then(Value::as_str).unwrap_or("").trim();
237 if text.is_empty() {
238 continue;
239 }
240
241 if !speakers.contains(&speaker) {
243 speakers.push(speaker.clone());
244 }
245 let role = if speakers.first() == Some(&speaker) {
246 "user"
247 } else {
248 "assistant"
249 };
250 pairs.push((role.to_string(), text.to_string()));
251 }
252
253 Ok(render_transcript(pairs))
254}
255
256fn render_transcript(items: impl IntoIterator<Item = (String, String)>) -> String {
257 let mut lines = Vec::new();
258
259 for (role, content) in items {
260 if content.trim().is_empty() {
261 continue;
262 }
263
264 if matches!(role.as_str(), "user" | "human") {
265 lines.push(format!("> {}", content.trim()));
266 } else {
267 lines.push(content.trim().to_string());
268 }
269 }
270
271 lines.join("\n")
272}