Skip to main content

smooth_operator/
domain.rs

1//! Domain model for smooth-operator.
2//!
3//! These structs mirror `spec/domain/*.json` exactly (field names, optionality)
4//! and are storage-agnostic — no backend is named here. They are the shapes the
5//! `StorageAdapter` (see [`crate::adapter`]) reads and writes.
6//!
7//! Checkpoints are *not* redefined here: we re-use smooth-operator's
8//! [`Checkpoint`](smooth_operator_core::Checkpoint) directly so the engine plugs
9//! straight into the checkpoint slice. See [`crate::adapter`].
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15
16// Re-export the engine's Checkpoint so callers get the domain "Checkpoint"
17// from one place. spec/domain/checkpoint.schema.json documents this struct
18// as "the `Checkpoint` struct in the smooth-operator Rust crate".
19pub use smooth_operator_core::Checkpoint;
20
21/// The channel on which a conversation takes place.
22/// Mirrors `conversation.schema.json#/properties/platform`.
23#[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/// A conversation thread between participants.
39/// Mirrors `conversation.schema.json`.
40#[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/// Participant role discriminator.
57/// Mirrors `participant.schema.json#/properties/type` (`user` | `ai-agent` | `human-agent`).
58#[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/// A participant in a conversation.
67/// Mirrors `participant.schema.json`.
68#[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/// Message direction relative to the platform.
98/// Mirrors `message.schema.json#/properties/direction`.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Direction {
102    Inbound,
103    Outbound,
104}
105
106/// A single content element within a message.
107/// Mirrors `message.schema.json#/$defs/ContentItem`.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ContentItem {
111    /// Content item type discriminator. Currently only `"text"` is defined.
112    #[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    /// Build a `text` content item.
120    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/// Structured content of a message.
129/// Mirrors `message.schema.json#/$defs/MessageContent`.
130#[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    /// Convenience: a single text item plus the flat-text mirror.
143    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/// Abbreviated sender/recipient descriptor (wire shape).
154/// Mirrors `message.schema.json#/properties/from` (and `to`).
155#[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/// A single message within a conversation.
166/// Mirrors `message.schema.json`.
167#[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/// Lifecycle status of a session.
193/// Mirrors `session.schema.json#/properties/status`.
194#[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/// An AI conversation session — ties a conversation to a smooth-operator
203/// workflow thread via `thread_id`.
204/// Mirrors `session.schema.json`.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Session {
208    pub session_id: String,
209    pub conversation_id: String,
210    /// Owning organization. Mirrors `organization_id` on `Conversation`,
211    /// `Participant`, and `Message` so org-scoping is uniform across every
212    /// core domain type — storage backends can write the session's org
213    /// directly instead of re-deriving it from the conversation.
214    pub organization_id: String,
215    pub agent_id: String,
216    pub agent_name: String,
217    pub user_participant_id: String,
218    pub agent_participant_id: String,
219    /// smooth-operator workflow thread identifier (the historical
220    /// `langgraph_thread_id`). Resumes agent state across turns.
221    pub thread_id: String,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub status: Option<SessionStatus>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub token_count: Option<u64>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub message_count: Option<u64>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub metadata: Option<HashMap<String, Value>>,
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub created_at: Option<DateTime<Utc>>,
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub updated_at: Option<DateTime<Utc>>,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub ended_at: Option<DateTime<Utc>>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub last_activity_at: Option<DateTime<Utc>>,
238}
239
240/// A source the agent used to ground its answer.
241///
242/// Mirrors `spec/domain/citation.schema.json` and the optional `citations`
243/// array on the terminal `eventual_response` event. Each citation points back at
244/// one retrieved knowledge-base document — the chunk the model read plus enough
245/// metadata to render an attribution link.
246///
247/// Citations are built from the
248/// [`KnowledgeResult`](smooth_operator_core::KnowledgeResult)s that actually
249/// grounded a turn (see [`Citation::from_knowledge_result`] /
250/// [`From<KnowledgeResult>`]): `id` ← `document_id`, `title` ← `source`,
251/// `url` ← `source` when it is an `http(s)` URL (the GitHub blob/issue URL the
252/// connector stamps onto the document's `source` at ingest — see
253/// `docs/CONNECTORS.md`) else `None`, `snippet` ← the chunk truncated for
254/// display, `score` ← `score`.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct Citation {
257    /// Stable identifier of the cited source document (the knowledge-base
258    /// `document_id`). Used to deduplicate citations within a turn.
259    pub id: String,
260    /// Human-readable label for the source — the document's source path or, for
261    /// web-sourced docs, the URL/title.
262    pub title: String,
263    /// Canonical link to the source, when one exists. For GitHub-sourced
264    /// documents this is the blob/issue URL stamped onto the document's `source`
265    /// at ingest. `None` for sources with no web location (e.g. uploaded files).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub url: Option<String>,
268    /// The retrieved chunk text that grounded the answer, truncated for display.
269    pub snippet: String,
270    /// Relevance score of this source for the turn's query (the knowledge-base
271    /// similarity score). Higher is more relevant.
272    pub score: f32,
273}
274
275/// Max characters of a chunk to carry as a citation `snippet`. Bounds the size
276/// of the `eventual_response` payload; the full chunk lives in the KB.
277pub const CITATION_SNIPPET_MAX_CHARS: usize = 280;
278
279impl Citation {
280    /// Build a [`Citation`] from a knowledge-base
281    /// [`KnowledgeResult`](smooth_operator_core::KnowledgeResult).
282    ///
283    /// - `id` ← `document_id`
284    /// - `title` ← `source`
285    /// - `url` ← `source` when it parses as an `http`/`https` URL (the GitHub
286    ///   blob/issue URL the connector stamps onto `Document.source` at ingest —
287    ///   `docs/CONNECTORS.md`), otherwise `None` (e.g. a local `policies/x.md`
288    ///   path has no web location).
289    /// - `snippet` ← `chunk`, truncated to [`CITATION_SNIPPET_MAX_CHARS`] on a
290    ///   char boundary (an ellipsis appended when truncated).
291    /// - `score` ← `score`
292    #[must_use]
293    pub fn from_knowledge_result(result: &smooth_operator_core::KnowledgeResult) -> Self {
294        Self {
295            id: result.document_id.clone(),
296            title: result.source.clone(),
297            url: web_url(&result.source),
298            snippet: truncate_snippet(&result.chunk, CITATION_SNIPPET_MAX_CHARS),
299            score: result.score,
300        }
301    }
302}
303
304impl From<&smooth_operator_core::KnowledgeResult> for Citation {
305    fn from(result: &smooth_operator_core::KnowledgeResult) -> Self {
306        Self::from_knowledge_result(result)
307    }
308}
309
310impl From<smooth_operator_core::KnowledgeResult> for Citation {
311    fn from(result: smooth_operator_core::KnowledgeResult) -> Self {
312        Self::from_knowledge_result(&result)
313    }
314}
315
316/// Return `Some(source)` when `source` is an `http`/`https` URL (the citation's
317/// `url`), else `None`. GitHub-sourced documents carry the blob/issue URL in
318/// `Document.source`; local docs carry a path, which has no web location.
319fn web_url(source: &str) -> Option<String> {
320    if source.starts_with("http://") || source.starts_with("https://") {
321        Some(source.to_string())
322    } else {
323        None
324    }
325}
326
327/// Truncate `text` to at most `max` chars on a char boundary, appending `…`
328/// when truncation occurred. Empty/short text is returned unchanged.
329fn truncate_snippet(text: &str, max: usize) -> String {
330    if text.chars().count() <= max {
331        return text.to_string();
332    }
333    let mut out: String = text.chars().take(max).collect();
334    out.push('…');
335    out
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use serde_json::json;
342
343    fn ts() -> DateTime<Utc> {
344        DateTime::parse_from_rfc3339("2026-06-07T12:00:00Z")
345            .unwrap()
346            .with_timezone(&Utc)
347    }
348
349    #[test]
350    fn participant_serializes_camelcase_and_kebab_type() {
351        let p = Participant {
352            id: "p1".into(),
353            conversation_id: "c1".into(),
354            organization_id: "org1".into(),
355            participant_type: ParticipantType::AiAgent,
356            external_id: None,
357            internal_id: Some("agent-uuid".into()),
358            browser_fingerprint: None,
359            browser_info: None,
360            name: "Smantha".into(),
361            email: None,
362            phone: None,
363            crm_contact_id: None,
364            metadata_json: None,
365            created_at: ts(),
366            updated_at: ts(),
367        };
368        let v = serde_json::to_value(&p).unwrap();
369        // camelCase field names match the spec
370        assert!(v.get("conversationId").is_some());
371        assert!(v.get("organizationId").is_some());
372        assert!(v.get("internalId").is_some());
373        // `type` discriminator is kebab-cased per the enum spec
374        assert_eq!(v.get("type").unwrap(), &json!("ai-agent"));
375        // round-trip
376        let back: Participant = serde_json::from_value(v).unwrap();
377        assert_eq!(back.participant_type, ParticipantType::AiAgent);
378    }
379
380    #[test]
381    fn participant_type_variants_match_spec() {
382        assert_eq!(
383            serde_json::to_value(ParticipantType::User).unwrap(),
384            json!("user")
385        );
386        assert_eq!(
387            serde_json::to_value(ParticipantType::AiAgent).unwrap(),
388            json!("ai-agent")
389        );
390        assert_eq!(
391            serde_json::to_value(ParticipantType::HumanAgent).unwrap(),
392            json!("human-agent")
393        );
394    }
395
396    #[test]
397    fn message_serializes_direction_and_content_items() {
398        let m = Message {
399            id: "m1".into(),
400            external_id: None,
401            organization_id: Some("org1".into()),
402            conversation_id: Some("c1".into()),
403            direction: Direction::Inbound,
404            content: MessageContent::from_text("hello"),
405            from: Some(ParticipantRef {
406                id: "p1".into(),
407                participant_type: "user".into(),
408                name: Some("Visitor".into()),
409            }),
410            to: None,
411            metadata_json: None,
412            analytics_json: None,
413            created_at: ts(),
414            updated_at: None,
415        };
416        let v = serde_json::to_value(&m).unwrap();
417        assert_eq!(v.get("direction").unwrap(), &json!("inbound"));
418        assert_eq!(v["content"]["items"][0]["type"], json!("text"));
419        assert_eq!(v["content"]["items"][0]["text"], json!("hello"));
420        assert_eq!(v["content"]["text"], json!("hello"));
421        // `from` uses camelCase `id`/`type`
422        assert_eq!(v["from"]["type"], json!("user"));
423        let back: Message = serde_json::from_value(v).unwrap();
424        assert_eq!(back.direction, Direction::Inbound);
425    }
426
427    #[test]
428    fn session_uses_thread_id_camelcase() {
429        let s = Session {
430            session_id: "s1".into(),
431            conversation_id: "c1".into(),
432            organization_id: "org1".into(),
433            agent_id: "a1".into(),
434            agent_name: "Smantha".into(),
435            user_participant_id: "pu".into(),
436            agent_participant_id: "pa".into(),
437            thread_id: "thread-xyz".into(),
438            status: Some(SessionStatus::Active),
439            token_count: Some(0),
440            message_count: Some(0),
441            metadata: None,
442            created_at: Some(ts()),
443            updated_at: Some(ts()),
444            ended_at: None,
445            last_activity_at: Some(ts()),
446        };
447        let v = serde_json::to_value(&s).unwrap();
448        assert!(v.get("sessionId").is_some());
449        assert!(v.get("conversationId").is_some());
450        assert!(v.get("userParticipantId").is_some());
451        assert!(v.get("agentParticipantId").is_some());
452        assert_eq!(v.get("threadId").unwrap(), &json!("thread-xyz"));
453        assert_eq!(v.get("status").unwrap(), &json!("active"));
454        let back: Session = serde_json::from_value(v).unwrap();
455        assert_eq!(back.thread_id, "thread-xyz");
456        assert_eq!(back.status, Some(SessionStatus::Active));
457    }
458
459    #[test]
460    fn conversation_platform_and_camelcase() {
461        let c = Conversation {
462            id: "c1".into(),
463            platform: Platform::Web,
464            name: "Lead chat".into(),
465            organization_id: "org1".into(),
466            idempotency_key: "idem-1".into(),
467            metadata_json: Some(json!({"campaign": "spring"})),
468            analytics_json: None,
469            created_at: ts(),
470            updated_at: ts(),
471        };
472        let v = serde_json::to_value(&c).unwrap();
473        assert_eq!(v.get("platform").unwrap(), &json!("web"));
474        assert!(v.get("organizationId").is_some());
475        assert!(v.get("idempotencyKey").is_some());
476        assert_eq!(v["metadataJson"]["campaign"], json!("spring"));
477        let back: Conversation = serde_json::from_value(v).unwrap();
478        assert_eq!(back.platform, Platform::Web);
479    }
480}