Skip to main content

opensession_core/
extract.rs

1use crate::{ContentBlock, EventType, Session};
2
3/// Metadata extracted from a session for DB storage at upload time.
4#[derive(Debug, Clone)]
5pub struct UploadMetadata {
6    pub title: Option<String>,
7    pub description: Option<String>,
8    pub tags: Option<String>,
9    pub created_at: String,
10}
11
12/// Extract upload metadata from a session, auto-generating title/description
13/// from the first user messages when the session's own metadata is empty.
14///
15/// This consolidates the duplicated logic in server and worker upload handlers.
16pub fn extract_upload_metadata(session: &Session) -> UploadMetadata {
17    let title = session
18        .context
19        .title
20        .clone()
21        .filter(|t| !t.is_empty())
22        .or_else(|| extract_first_user_text(session).map(|t| truncate_str(&t, 80)));
23
24    let description = session
25        .context
26        .description
27        .clone()
28        .filter(|d| !d.is_empty())
29        .or_else(|| extract_user_texts(session, 3).map(|t| truncate_str(&t, 500)));
30
31    let tags = if session.context.tags.is_empty() {
32        None
33    } else {
34        Some(session.context.tags.join(","))
35    };
36
37    let created_at = session.context.created_at.to_rfc3339();
38
39    UploadMetadata {
40        title,
41        description,
42        tags,
43        created_at,
44    }
45}
46
47/// Extract the text from the first UserMessage event.
48pub fn extract_first_user_text(session: &Session) -> Option<String> {
49    for event in &session.events {
50        if matches!(event.event_type, EventType::UserMessage) {
51            for block in &event.content.blocks {
52                if let ContentBlock::Text { text } = block {
53                    let trimmed = text.trim();
54                    if !trimmed.is_empty() {
55                        return Some(trimmed.to_string());
56                    }
57                }
58            }
59        }
60    }
61    None
62}
63
64/// Extract and join texts from the first `max` UserMessage events.
65pub fn extract_user_texts(session: &Session, max: usize) -> Option<String> {
66    let mut texts = Vec::new();
67    for event in &session.events {
68        if texts.len() >= max {
69            break;
70        }
71        if matches!(event.event_type, EventType::UserMessage) {
72            for block in &event.content.blocks {
73                if let ContentBlock::Text { text } = block {
74                    let trimmed = text.trim();
75                    if !trimmed.is_empty() {
76                        texts.push(trimmed.to_string());
77                        break;
78                    }
79                }
80            }
81        }
82    }
83    if texts.is_empty() {
84        None
85    } else {
86        Some(texts.join(" "))
87    }
88}
89
90/// Truncate a string to `max_len` characters, appending "..." if truncated.
91pub fn truncate_str(s: &str, max_len: usize) -> String {
92    if s.len() <= max_len {
93        s.to_string()
94    } else {
95        let mut end = max_len.saturating_sub(3);
96        // Don't split in the middle of a multi-byte char
97        while end > 0 && !s.is_char_boundary(end) {
98            end -= 1;
99        }
100        format!("{}...", &s[..end])
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::{Agent, Content, Event, Session};
108    use chrono::Utc;
109    use std::collections::HashMap;
110
111    fn make_session(messages: Vec<(&str, EventType)>) -> Session {
112        let mut session = Session::new(
113            "test".to_string(),
114            Agent {
115                provider: "test".to_string(),
116                model: "test".to_string(),
117                tool: "test".to_string(),
118                tool_version: None,
119            },
120        );
121        for (i, (text, event_type)) in messages.into_iter().enumerate() {
122            session.events.push(Event {
123                event_id: format!("e{i}"),
124                timestamp: Utc::now(),
125                event_type,
126                task_id: None,
127                content: Content::text(text),
128                duration_ms: None,
129                attributes: HashMap::new(),
130            });
131        }
132        session
133    }
134
135    #[test]
136    fn test_extract_first_user_text() {
137        let session = make_session(vec![
138            ("hello world", EventType::UserMessage),
139            ("second message", EventType::UserMessage),
140        ]);
141        assert_eq!(
142            extract_first_user_text(&session),
143            Some("hello world".to_string())
144        );
145    }
146
147    #[test]
148    fn test_extract_first_user_text_skips_agent() {
149        let session = make_session(vec![
150            ("agent reply", EventType::AgentMessage),
151            ("user msg", EventType::UserMessage),
152        ]);
153        assert_eq!(
154            extract_first_user_text(&session),
155            Some("user msg".to_string())
156        );
157    }
158
159    #[test]
160    fn test_extract_first_user_text_empty() {
161        let session = make_session(vec![("agent reply", EventType::AgentMessage)]);
162        assert_eq!(extract_first_user_text(&session), None);
163    }
164
165    #[test]
166    fn test_extract_user_texts() {
167        let session = make_session(vec![
168            ("first", EventType::UserMessage),
169            ("reply", EventType::AgentMessage),
170            ("second", EventType::UserMessage),
171            ("third", EventType::UserMessage),
172        ]);
173        assert_eq!(
174            extract_user_texts(&session, 2),
175            Some("first second".to_string())
176        );
177    }
178
179    #[test]
180    fn test_truncate_str_short() {
181        assert_eq!(truncate_str("hello", 10), "hello");
182    }
183
184    #[test]
185    fn test_truncate_str_exact() {
186        assert_eq!(truncate_str("hello", 5), "hello");
187    }
188
189    #[test]
190    fn test_truncate_str_long() {
191        assert_eq!(truncate_str("hello world", 8), "hello...");
192    }
193
194    #[test]
195    fn test_extract_upload_metadata_auto_title() {
196        let session = make_session(vec![
197            ("Build a REST API", EventType::UserMessage),
198            ("Sure, let me help", EventType::AgentMessage),
199            ("Add auth too", EventType::UserMessage),
200        ]);
201        let meta = extract_upload_metadata(&session);
202        assert_eq!(meta.title.as_deref(), Some("Build a REST API"));
203        // description joins first 3 user messages
204        assert_eq!(
205            meta.description.as_deref(),
206            Some("Build a REST API Add auth too")
207        );
208        assert!(meta.tags.is_none());
209    }
210
211    #[test]
212    fn test_extract_upload_metadata_explicit_title() {
213        let mut session = make_session(vec![("hello", EventType::UserMessage)]);
214        session.context.title = Some("My Title".to_string());
215        session.context.description = Some("My Desc".to_string());
216        session.context.tags = vec!["rust".to_string(), "api".to_string()];
217
218        let meta = extract_upload_metadata(&session);
219        assert_eq!(meta.title.as_deref(), Some("My Title"));
220        assert_eq!(meta.description.as_deref(), Some("My Desc"));
221        assert_eq!(meta.tags.as_deref(), Some("rust,api"));
222    }
223
224    #[test]
225    fn test_extract_upload_metadata_empty_strings() {
226        let mut session = make_session(vec![("hello", EventType::UserMessage)]);
227        session.context.title = Some("".to_string());
228        session.context.description = Some("".to_string());
229
230        let meta = extract_upload_metadata(&session);
231        // Empty strings should trigger auto-extraction
232        assert_eq!(meta.title.as_deref(), Some("hello"));
233        assert_eq!(meta.description.as_deref(), Some("hello"));
234    }
235}