Skip to main content

systemprompt_agent/services/a2a_server/processing/
conversation_service.rs

1//! Conversation-history assembly for AI requests.
2//!
3//! [`ConversationService`] reads prior tasks for a context and flattens their
4//! messages and artifacts into a `Vec<AiMessage>`, decoding supported file
5//! parts into [`AiContentPart`]s and serializing artifacts back into textual
6//! context.
7
8use crate::services::shared::{AgentServiceError, Result};
9use base64::Engine;
10use systemprompt_database::DbPool;
11use systemprompt_identifiers::ContextId;
12use systemprompt_models::{
13    AiContentPart, AiMessage, MessageRole, is_supported_audio, is_supported_image,
14    is_supported_text, is_supported_video,
15};
16
17use crate::models::a2a::{FilePart, MessageRole as A2aMessageRole, Part};
18use crate::models::{Artifact, Message};
19use crate::repository::task::TaskRepository;
20
21#[derive(Debug)]
22pub struct ConversationService {
23    db_pool: DbPool,
24}
25
26impl ConversationService {
27    pub const fn new(db_pool: DbPool) -> Self {
28        Self { db_pool }
29    }
30
31    pub async fn load_conversation_history(
32        &self,
33        context_id: &ContextId,
34    ) -> Result<Vec<AiMessage>> {
35        let task_repo = TaskRepository::new(&self.db_pool)?;
36        let tasks = task_repo
37            .list_tasks_by_context(context_id)
38            .await
39            .map_err(|e| {
40                AgentServiceError::Internal(format!("Failed to load conversation history: {e}"))
41            })?;
42
43        let mut history_messages = Vec::new();
44
45        for task in tasks {
46            if let Some(task_history) = task.history {
47                for msg in task_history {
48                    let (text, parts) = Self::extract_message_content(&msg);
49                    if text.is_empty() && parts.is_empty() {
50                        continue;
51                    }
52
53                    let role = match msg.role {
54                        A2aMessageRole::User => MessageRole::User,
55                        A2aMessageRole::Agent => MessageRole::Assistant,
56                    };
57
58                    history_messages.push(AiMessage {
59                        role,
60                        content: text,
61                        parts,
62                    });
63                }
64            }
65
66            if let Some(artifacts) = task.artifacts {
67                for artifact in artifacts {
68                    let artifact_content = Self::serialize_artifact_for_context(&artifact);
69                    history_messages.push(AiMessage {
70                        role: MessageRole::Assistant,
71                        content: artifact_content,
72                        parts: Vec::new(),
73                    });
74                }
75            }
76        }
77
78        Ok(history_messages)
79    }
80
81    #[doc(hidden)]
82    pub fn extract_message_content(message: &Message) -> (String, Vec<AiContentPart>) {
83        let mut text_content = String::new();
84        let mut content_parts = Vec::new();
85
86        for part in &message.parts {
87            match part {
88                Part::Text(text_part) => {
89                    if text_content.is_empty() {
90                        text_content.clone_from(&text_part.text);
91                    }
92                    content_parts.push(AiContentPart::text(&text_part.text));
93                },
94                Part::File(file_part) => {
95                    if let Some(content_part) = Self::file_to_content_part(file_part) {
96                        content_parts.push(content_part);
97                    }
98                },
99                Part::Data(_) => {},
100            }
101        }
102
103        (text_content, content_parts)
104    }
105
106    #[doc(hidden)]
107    pub fn file_to_content_part(file_part: &FilePart) -> Option<AiContentPart> {
108        let mime_type = file_part.file.mime_type.as_deref()?;
109        let file_name = file_part.file.name.as_deref().unwrap_or("unnamed");
110
111        let bytes = file_part.file.bytes.as_deref()?;
112
113        if is_supported_image(mime_type) {
114            return Some(AiContentPart::image(mime_type, bytes));
115        }
116
117        if is_supported_audio(mime_type) {
118            return Some(AiContentPart::audio(mime_type, bytes));
119        }
120
121        if is_supported_video(mime_type) {
122            return Some(AiContentPart::video(mime_type, bytes));
123        }
124
125        if is_supported_text(mime_type) {
126            return Self::decode_text_file(bytes, file_name, mime_type);
127        }
128
129        tracing::warn!(
130            file_name = %file_name,
131            mime_type = %mime_type,
132            "Unsupported file type - file will not be sent to AI"
133        );
134        None
135    }
136
137    #[doc(hidden)]
138    pub fn decode_text_file(
139        bytes: &str,
140        file_name: &str,
141        mime_type: &str,
142    ) -> Option<AiContentPart> {
143        let decoded = base64::engine::general_purpose::STANDARD
144            .decode(bytes)
145            .map_err(|e| {
146                tracing::warn!(
147                    file_name = %file_name,
148                    mime_type = %mime_type,
149                    error = %e,
150                    "Failed to decode base64 text file"
151                );
152                e
153            })
154            .ok()?;
155
156        let text_content = String::from_utf8(decoded)
157            .map_err(|e| {
158                tracing::warn!(
159                    file_name = %file_name,
160                    mime_type = %mime_type,
161                    error = %e,
162                    "Failed to decode text file as UTF-8"
163                );
164                e
165            })
166            .ok()?;
167
168        let formatted = format!("[File: {file_name} ({mime_type})]\n{text_content}");
169        Some(AiContentPart::text(formatted))
170    }
171
172    #[doc(hidden)]
173    pub fn serialize_artifact_for_context(artifact: &Artifact) -> String {
174        let artifact_name = artifact
175            .title
176            .clone()
177            .unwrap_or_else(|| "unnamed".to_owned());
178
179        let mut content = format!(
180            "[Artifact: {} (type: {})]\n",
181            artifact_name, artifact.metadata.artifact_type
182        );
183
184        for part in &artifact.parts {
185            match part {
186                Part::Text(text_part) => {
187                    content.push_str(&text_part.text);
188                    content.push('\n');
189                },
190                Part::Data(data_part) => {
191                    let json_str = serde_json::to_string_pretty(&data_part.data)
192                        .unwrap_or_else(|_| "{}".to_owned());
193                    content.push_str(&json_str);
194                    content.push('\n');
195                },
196                Part::File(file_part) => {
197                    if let Some(name) = &file_part.file.name {
198                        content.push_str(&format!("[File: {}]\n", name));
199                    }
200                },
201            }
202        }
203
204        content
205    }
206}