1use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15
16pub use smooth_operator_core::Checkpoint;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Platform {
26 Web,
27 Messenger,
28 Instagram,
29 Email,
30 Discord,
31 Phone,
32 Sms,
33 Slack,
34 Whatsapp,
35 Tiktok,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct Conversation {
43 pub id: String,
44 pub platform: Platform,
45 pub name: String,
46 pub organization_id: String,
47 pub idempotency_key: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub metadata_json: Option<Value>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub analytics_json: Option<Value>,
52 pub created_at: DateTime<Utc>,
53 pub updated_at: DateTime<Utc>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum ParticipantType {
61 User,
62 AiAgent,
63 HumanAgent,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct Participant {
71 pub id: String,
72 pub conversation_id: String,
73 pub organization_id: String,
74 #[serde(rename = "type")]
75 pub participant_type: ParticipantType,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub external_id: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub internal_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub browser_fingerprint: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub browser_info: Option<Value>,
84 pub name: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub email: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub phone: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub crm_contact_id: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub metadata_json: Option<Value>,
93 pub created_at: DateTime<Utc>,
94 pub updated_at: DateTime<Utc>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Direction {
102 Inbound,
103 Outbound,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ContentItem {
111 #[serde(rename = "type")]
113 pub item_type: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub text: Option<String>,
116}
117
118impl ContentItem {
119 pub fn text(text: impl Into<String>) -> Self {
121 Self {
122 item_type: "text".to_string(),
123 text: Some(text.into()),
124 }
125 }
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct MessageContent {
133 #[serde(default)]
134 pub items: Vec<ContentItem>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub text: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub structured_response: Option<Value>,
139}
140
141impl MessageContent {
142 pub fn from_text(text: impl Into<String>) -> Self {
144 let text = text.into();
145 Self {
146 items: vec![ContentItem::text(text.clone())],
147 text: Some(text),
148 structured_response: None,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ParticipantRef {
158 pub id: String,
159 #[serde(rename = "type")]
160 pub participant_type: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub name: Option<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct Message {
170 pub id: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub external_id: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub organization_id: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub conversation_id: Option<String>,
177 pub direction: Direction,
178 pub content: MessageContent,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub from: Option<ParticipantRef>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub to: Option<ParticipantRef>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub metadata_json: Option<Value>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub analytics_json: Option<Value>,
187 pub created_at: DateTime<Utc>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub updated_at: Option<DateTime<Utc>>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum SessionStatus {
197 Active,
198 Idle,
199 Ended,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Session {
208 pub session_id: String,
209 pub conversation_id: String,
210 pub agent_id: String,
211 pub agent_name: String,
212 pub user_participant_id: String,
213 pub agent_participant_id: String,
214 pub thread_id: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub status: Option<SessionStatus>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub token_count: Option<u64>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub message_count: Option<u64>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub metadata: Option<HashMap<String, Value>>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub created_at: Option<DateTime<Utc>>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub updated_at: Option<DateTime<Utc>>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub ended_at: Option<DateTime<Utc>>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub last_activity_at: Option<DateTime<Utc>>,
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251pub struct Citation {
252 pub id: String,
255 pub title: String,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub url: Option<String>,
263 pub snippet: String,
265 pub score: f32,
268}
269
270pub const CITATION_SNIPPET_MAX_CHARS: usize = 280;
273
274impl Citation {
275 #[must_use]
288 pub fn from_knowledge_result(result: &smooth_operator_core::KnowledgeResult) -> Self {
289 Self {
290 id: result.document_id.clone(),
291 title: result.source.clone(),
292 url: web_url(&result.source),
293 snippet: truncate_snippet(&result.chunk, CITATION_SNIPPET_MAX_CHARS),
294 score: result.score,
295 }
296 }
297}
298
299impl From<&smooth_operator_core::KnowledgeResult> for Citation {
300 fn from(result: &smooth_operator_core::KnowledgeResult) -> Self {
301 Self::from_knowledge_result(result)
302 }
303}
304
305impl From<smooth_operator_core::KnowledgeResult> for Citation {
306 fn from(result: smooth_operator_core::KnowledgeResult) -> Self {
307 Self::from_knowledge_result(&result)
308 }
309}
310
311fn web_url(source: &str) -> Option<String> {
315 if source.starts_with("http://") || source.starts_with("https://") {
316 Some(source.to_string())
317 } else {
318 None
319 }
320}
321
322fn truncate_snippet(text: &str, max: usize) -> String {
325 if text.chars().count() <= max {
326 return text.to_string();
327 }
328 let mut out: String = text.chars().take(max).collect();
329 out.push('…');
330 out
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use serde_json::json;
337
338 fn ts() -> DateTime<Utc> {
339 DateTime::parse_from_rfc3339("2026-06-07T12:00:00Z")
340 .unwrap()
341 .with_timezone(&Utc)
342 }
343
344 #[test]
345 fn participant_serializes_camelcase_and_kebab_type() {
346 let p = Participant {
347 id: "p1".into(),
348 conversation_id: "c1".into(),
349 organization_id: "org1".into(),
350 participant_type: ParticipantType::AiAgent,
351 external_id: None,
352 internal_id: Some("agent-uuid".into()),
353 browser_fingerprint: None,
354 browser_info: None,
355 name: "Smantha".into(),
356 email: None,
357 phone: None,
358 crm_contact_id: None,
359 metadata_json: None,
360 created_at: ts(),
361 updated_at: ts(),
362 };
363 let v = serde_json::to_value(&p).unwrap();
364 assert!(v.get("conversationId").is_some());
366 assert!(v.get("organizationId").is_some());
367 assert!(v.get("internalId").is_some());
368 assert_eq!(v.get("type").unwrap(), &json!("ai-agent"));
370 let back: Participant = serde_json::from_value(v).unwrap();
372 assert_eq!(back.participant_type, ParticipantType::AiAgent);
373 }
374
375 #[test]
376 fn participant_type_variants_match_spec() {
377 assert_eq!(
378 serde_json::to_value(ParticipantType::User).unwrap(),
379 json!("user")
380 );
381 assert_eq!(
382 serde_json::to_value(ParticipantType::AiAgent).unwrap(),
383 json!("ai-agent")
384 );
385 assert_eq!(
386 serde_json::to_value(ParticipantType::HumanAgent).unwrap(),
387 json!("human-agent")
388 );
389 }
390
391 #[test]
392 fn message_serializes_direction_and_content_items() {
393 let m = Message {
394 id: "m1".into(),
395 external_id: None,
396 organization_id: Some("org1".into()),
397 conversation_id: Some("c1".into()),
398 direction: Direction::Inbound,
399 content: MessageContent::from_text("hello"),
400 from: Some(ParticipantRef {
401 id: "p1".into(),
402 participant_type: "user".into(),
403 name: Some("Visitor".into()),
404 }),
405 to: None,
406 metadata_json: None,
407 analytics_json: None,
408 created_at: ts(),
409 updated_at: None,
410 };
411 let v = serde_json::to_value(&m).unwrap();
412 assert_eq!(v.get("direction").unwrap(), &json!("inbound"));
413 assert_eq!(v["content"]["items"][0]["type"], json!("text"));
414 assert_eq!(v["content"]["items"][0]["text"], json!("hello"));
415 assert_eq!(v["content"]["text"], json!("hello"));
416 assert_eq!(v["from"]["type"], json!("user"));
418 let back: Message = serde_json::from_value(v).unwrap();
419 assert_eq!(back.direction, Direction::Inbound);
420 }
421
422 #[test]
423 fn session_uses_thread_id_camelcase() {
424 let s = Session {
425 session_id: "s1".into(),
426 conversation_id: "c1".into(),
427 agent_id: "a1".into(),
428 agent_name: "Smantha".into(),
429 user_participant_id: "pu".into(),
430 agent_participant_id: "pa".into(),
431 thread_id: "thread-xyz".into(),
432 status: Some(SessionStatus::Active),
433 token_count: Some(0),
434 message_count: Some(0),
435 metadata: None,
436 created_at: Some(ts()),
437 updated_at: Some(ts()),
438 ended_at: None,
439 last_activity_at: Some(ts()),
440 };
441 let v = serde_json::to_value(&s).unwrap();
442 assert!(v.get("sessionId").is_some());
443 assert!(v.get("conversationId").is_some());
444 assert!(v.get("userParticipantId").is_some());
445 assert!(v.get("agentParticipantId").is_some());
446 assert_eq!(v.get("threadId").unwrap(), &json!("thread-xyz"));
447 assert_eq!(v.get("status").unwrap(), &json!("active"));
448 let back: Session = serde_json::from_value(v).unwrap();
449 assert_eq!(back.thread_id, "thread-xyz");
450 assert_eq!(back.status, Some(SessionStatus::Active));
451 }
452
453 #[test]
454 fn conversation_platform_and_camelcase() {
455 let c = Conversation {
456 id: "c1".into(),
457 platform: Platform::Web,
458 name: "Lead chat".into(),
459 organization_id: "org1".into(),
460 idempotency_key: "idem-1".into(),
461 metadata_json: Some(json!({"campaign": "spring"})),
462 analytics_json: None,
463 created_at: ts(),
464 updated_at: ts(),
465 };
466 let v = serde_json::to_value(&c).unwrap();
467 assert_eq!(v.get("platform").unwrap(), &json!("web"));
468 assert!(v.get("organizationId").is_some());
469 assert!(v.get("idempotencyKey").is_some());
470 assert_eq!(v["metadataJson"]["campaign"], json!("spring"));
471 let back: Conversation = serde_json::from_value(v).unwrap();
472 assert_eq!(back.platform, Platform::Web);
473 }
474}