Skip to main content

kcode_kennedy_sessions/
services.rs

1//! Typed access to Kennedy's in-process service capabilities.
2
3use std::sync::Arc;
4
5use kcode_kweb_db::{Node, NodeId, ObjectId, Provenance};
6use kcode_kweb_manager::KwebManager;
7use kcode_server_object_envelopes::{StoredFile, decode_file, encode_file};
8use serde_json::Value;
9use uuid::Uuid;
10
11#[derive(Clone)]
12pub struct LocalServices {
13    pub kmap: KwebManager,
14    pub intelligence: kcode_intelligence_router::Intelligence,
15    pub history: kcode_session_history::SessionHistory,
16    pub speech_classifier: Arc<kcode_speech_classification::SpeechClassifier>,
17    pub dev_tools: kcode_dev_tools::Service,
18    pub agents: kcode_agent_runtime::AgentRuntime,
19    pub telegram: kcode_telegram_session_coordinator::Service,
20}
21
22#[derive(Debug, Clone)]
23pub(crate) struct ApiError {
24    pub(crate) message: String,
25    pub(crate) receipt: Option<Box<kcode_intelligence_router::UsageReceipt>>,
26}
27
28impl std::fmt::Display for ApiError {
29    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        formatter.write_str(&self.message)
31    }
32}
33
34impl std::error::Error for ApiError {}
35
36#[derive(Clone)]
37pub struct Api {
38    services: Arc<LocalServices>,
39}
40
41impl Api {
42    pub fn new(services: LocalServices) -> Self {
43        Self {
44            services: Arc::new(services),
45        }
46    }
47
48    pub(crate) fn kmap(&self) -> &KwebManager {
49        &self.services.kmap
50    }
51
52    pub(crate) fn telegram(&self) -> &kcode_telegram_session_coordinator::Service {
53        &self.services.telegram
54    }
55
56    pub(crate) fn create_history_session(
57        &self,
58        input: kcode_session_history::NewSession,
59    ) -> anyhow::Result<kcode_session_history::Session> {
60        self.services.history.create_session(input)
61    }
62
63    pub(crate) fn history_session(
64        &self,
65        metadata: kcode_session_history::chatend::SessionMetadata,
66        provider_model: &str,
67    ) -> anyhow::Result<kcode_session_history::Session> {
68        self.services
69            .history
70            .open_session_with_provider_model(metadata, Some(provider_model))
71    }
72
73    pub(crate) fn kmap_node(&self, node_id: &str) -> Result<Node, ApiError> {
74        let node_id = node_id.parse::<NodeId>().map_err(local_api_error)?;
75        self.services.kmap.get_node(node_id).map_err(kmap_error)
76    }
77
78    pub(crate) fn commit_kweb_session(
79        &self,
80        input: kcode_commit_session::CommitRequest,
81    ) -> Result<kcode_commit_session::CommitReceipt, ApiError> {
82        self.services.kmap.commit_session(input).map_err(kmap_error)
83    }
84
85    pub(crate) fn kmap_file(&self, object_id: &str) -> Result<StoredFile, ApiError> {
86        let object_id = object_id.parse::<ObjectId>().map_err(local_api_error)?;
87        let bytes = self
88            .services
89            .kmap
90            .get_object(object_id)
91            .map_err(kmap_error)?;
92        decode_file(object_id, bytes).map_err(local_api_error)
93    }
94
95    pub(crate) fn save_generated_image(
96        &self,
97        bytes: Vec<u8>,
98        file_name: &str,
99        media_type: &str,
100        model: &str,
101    ) -> Result<String, ApiError> {
102        let bytes = encode_file(
103            "generated-image",
104            Some(file_name),
105            media_type,
106            Some("image"),
107            bytes,
108        )
109        .map_err(local_api_error)?;
110        self.services
111            .kmap
112            .store_object(
113                Provenance {
114                    author: model.into(),
115                    source: "kennedy-generated-image".into(),
116                    source_created_at: chrono::Utc::now(),
117                    data: "Image generated or modified through Kennedy intelligence.".into(),
118                },
119                bytes,
120            )
121            .map(|id| id.to_string())
122            .map_err(kmap_error)
123    }
124
125    pub(crate) fn agent_runtime(&self) -> kcode_agent_runtime::AgentRuntime {
126        self.services.agents.clone()
127    }
128
129    pub(crate) async fn search(
130        &self,
131        user_id: &str,
132        request: kcode_intelligence_router::SearchRequest,
133    ) -> Result<
134        kcode_intelligence_router::Accounted<kcode_intelligence_router::SearchResponse>,
135        ApiError,
136    > {
137        self.services
138            .intelligence
139            .for_user(user_id)
140            .map_err(intelligence_error)?
141            .search(request)
142            .await
143            .map_err(intelligence_error)
144    }
145
146    pub(crate) async fn fetch(
147        &self,
148        user_id: &str,
149        request: kcode_intelligence_router::FetchRequest,
150    ) -> Result<kcode_intelligence_router::FetchResponse, ApiError> {
151        self.services
152            .intelligence
153            .for_user(user_id)
154            .map_err(intelligence_error)?
155            .fetch(request)
156            .await
157            .map_err(intelligence_error)
158    }
159
160    pub(crate) async fn managed_source_execute(
161        &self,
162        session_id: &str,
163        name: &str,
164        arguments: Value,
165        objects: Vec<Vec<u8>>,
166    ) -> Result<kcode_dev_tools::ToolExecution, ApiError> {
167        let mut execution = self
168            .services
169            .dev_tools
170            .execute(session_id.to_owned(), name.to_owned(), arguments, objects)
171            .await
172            .map_err(dev_tools_error)?;
173        let mut object_ids = Vec::with_capacity(execution.objects.len());
174        for bytes in std::mem::take(&mut execution.objects) {
175            object_ids.push(
176                self.services
177                    .kmap
178                    .store_object(
179                        Provenance {
180                            author: "Kennedy".into(),
181                            source: "kennedy-rust-binary".into(),
182                            source_created_at: chrono::Utc::now(),
183                            data: "Output payload from a managed Rust-binary call.".into(),
184                        },
185                        bytes,
186                    )
187                    .map_err(kmap_error)?
188                    .to_string(),
189            );
190        }
191        append_object_ids(&mut execution.text, &object_ids);
192        Ok(execution)
193    }
194
195    pub(crate) async fn execute_speech_classification_tool(
196        &self,
197        name: &str,
198        arguments: Value,
199    ) -> Result<String, ApiError> {
200        let call = kcode_speech_classification::decode_ktool(name, &arguments)
201            .map_err(speech_ktool_error)?;
202        let classifier = Arc::clone(&self.services.speech_classifier);
203        tokio::task::spawn_blocking(move || classifier.execute_ktool(call))
204            .await
205            .map_err(speech_task_error)?
206            .map_err(speech_ktool_error)
207    }
208
209    pub(crate) async fn release_managed_sources(&self, session_id: &str) {
210        if let Err(error) = self.services.dev_tools.release(session_id.to_owned()).await {
211            tracing::warn!(error=%error.message, "Managed-source session release failed");
212        }
213    }
214
215    #[allow(clippy::too_many_arguments)]
216    pub(crate) async fn transcribe_audio(
217        &self,
218        user_id: &str,
219        model: &str,
220        prompt: &str,
221        bytes: Vec<u8>,
222        filename: String,
223        mime: &str,
224        temperature: Option<f32>,
225        parent_operation_id: Uuid,
226    ) -> Result<
227        kcode_intelligence_router::Accounted<kcode_intelligence_router::TranscriptionResponse>,
228        ApiError,
229    > {
230        self.services
231            .intelligence
232            .for_user(user_id)
233            .map_err(intelligence_error)?
234            .transcribe(kcode_intelligence_router::TranscriptionRequest {
235                prompt: prompt.to_owned(),
236                model: model.to_owned(),
237                media: kcode_intelligence_router::Media::audio(bytes, filename, mime)
238                    .map_err(intelligence_error)?,
239                temperature,
240                operation_id: Uuid::new_v4(),
241                parent_operation_id: Some(parent_operation_id),
242            })
243            .await
244            .map_err(intelligence_error)
245    }
246
247    pub(crate) async fn extract_document(
248        &self,
249        bytes: Vec<u8>,
250        filename: String,
251        mime: &str,
252    ) -> Result<kcode_intelligence_router::DocumentExtraction, ApiError> {
253        self.services
254            .intelligence
255            .extract_document(kcode_intelligence_router::Document {
256                bytes,
257                file_name: filename,
258                content_type: mime.to_owned(),
259            })
260            .await
261            .map_err(intelligence_error)
262    }
263
264    #[allow(clippy::too_many_arguments)]
265    pub(crate) async fn annotate_media(
266        &self,
267        user_id: &str,
268        model: &str,
269        prompt: &str,
270        bytes: Vec<u8>,
271        filename: String,
272        mime: &str,
273        parent_operation_id: Uuid,
274    ) -> Result<
275        kcode_intelligence_router::Accounted<kcode_intelligence_router::AnnotationResponse>,
276        ApiError,
277    > {
278        self.services
279            .intelligence
280            .for_user(user_id)
281            .map_err(intelligence_error)?
282            .annotate(kcode_intelligence_router::AnnotationRequest {
283                prompt: prompt.to_owned(),
284                model: model.to_owned(),
285                media: media_for_annotation(bytes, filename, mime).map_err(intelligence_error)?,
286                operation_id: Uuid::new_v4(),
287                parent_operation_id: Some(parent_operation_id),
288            })
289            .await
290            .map_err(intelligence_error)
291    }
292
293    pub(crate) async fn generate_image(
294        &self,
295        user_id: &str,
296        model: &str,
297        prompt: &str,
298        references: Vec<(Vec<u8>, String, String)>,
299        parent_operation_id: Uuid,
300    ) -> Result<
301        kcode_intelligence_router::Accounted<kcode_intelligence_router::ImageResponse>,
302        ApiError,
303    > {
304        let references = references
305            .into_iter()
306            .map(|(bytes, filename, mime)| {
307                media_for_image(bytes, filename, &mime).map_err(intelligence_error)
308            })
309            .collect::<Result<Vec<_>, _>>()?;
310        self.services
311            .intelligence
312            .for_user(user_id)
313            .map_err(intelligence_error)?
314            .generate_image(kcode_intelligence_router::ImageRequest {
315                model: model.to_owned(),
316                prompt: prompt.to_owned(),
317                references,
318                operation_id: Uuid::new_v4(),
319                parent_operation_id: Some(parent_operation_id),
320            })
321            .await
322            .map_err(intelligence_error)
323    }
324}
325
326fn kmap_error(error: kcode_kweb_manager::Error) -> ApiError {
327    let message = match error.kind() {
328        kcode_kweb_manager::ErrorKind::InvalidInput
329        | kcode_kweb_manager::ErrorKind::NotFound
330        | kcode_kweb_manager::ErrorKind::Conflict => error.to_string(),
331        _ => "An unexpected Kmap database error occurred.".into(),
332    };
333    ApiError {
334        message,
335        receipt: None,
336    }
337}
338
339fn intelligence_error(error: kcode_intelligence_router::Error) -> ApiError {
340    ApiError {
341        message: error.message().into(),
342        receipt: error.receipt().cloned().map(Box::new),
343    }
344}
345
346fn append_object_ids(text: &mut String, object_ids: &[String]) {
347    if object_ids.is_empty() {
348        return;
349    }
350    if !text.is_empty() && !text.ends_with('\n') {
351        text.push('\n');
352    }
353    text.push_str(&object_ids.join("\n"));
354}
355
356fn dev_tools_error(error: kcode_dev_tools::ToolError) -> ApiError {
357    ApiError {
358        message: error.message,
359        receipt: None,
360    }
361}
362
363fn speech_task_error(error: tokio::task::JoinError) -> ApiError {
364    tracing::error!(%error, "In-process speaker-classification task stopped unexpectedly");
365    ApiError {
366        message: "An unexpected Kennedy speaker-classification error occurred.".into(),
367        receipt: None,
368    }
369}
370
371fn speech_ktool_error(error: kcode_speech_classification::KtoolError) -> ApiError {
372    let kcode_speech_classification::KtoolError::Classifier(error) = error else {
373        return ApiError {
374            message: error.to_string(),
375            receipt: None,
376        };
377    };
378    let internal = matches!(
379        error,
380        kcode_speech_classification::Error::UnsupportedSchema { .. }
381            | kcode_speech_classification::Error::Storage(_)
382            | kcode_speech_classification::Error::CorruptStorage(_)
383    );
384    ApiError {
385        message: if internal {
386            tracing::error!(%error, "Speaker-classification storage failed");
387            "An unexpected Kennedy speaker-classification error occurred.".into()
388        } else {
389            error.to_string()
390        },
391        receipt: None,
392    }
393}
394
395fn local_api_error(error: impl std::fmt::Display) -> ApiError {
396    ApiError {
397        message: error.to_string(),
398        receipt: None,
399    }
400}
401
402fn media_for_annotation(
403    bytes: Vec<u8>,
404    filename: String,
405    mime: &str,
406) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
407    let normalized = mime
408        .split(';')
409        .next()
410        .unwrap_or("application/octet-stream")
411        .trim()
412        .to_ascii_lowercase();
413    let kind = if normalized.starts_with("image/") {
414        kcode_intelligence_router::MediaKind::Image
415    } else if normalized.starts_with("audio/")
416        || matches!(normalized.as_str(), "application/ogg" | "video/ogg")
417        || filename.rsplit_once('.').is_some_and(|(_, extension)| {
418            matches!(
419                extension.to_ascii_lowercase().as_str(),
420                "ogg" | "oga" | "opus"
421            )
422        })
423    {
424        kcode_intelligence_router::MediaKind::Audio
425    } else if normalized.starts_with("video/") {
426        kcode_intelligence_router::MediaKind::Video
427    } else {
428        return Err(kcode_intelligence_router::Error::invalid(
429            "annotation requires image, audio, or video media",
430        ));
431    };
432    kcode_intelligence_router::Media::new(kind, bytes, filename, normalized)
433}
434
435fn media_for_image(
436    bytes: Vec<u8>,
437    filename: String,
438    mime: &str,
439) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
440    let normalized = mime
441        .split(';')
442        .next()
443        .unwrap_or("application/octet-stream")
444        .trim()
445        .to_ascii_lowercase();
446    if !normalized.starts_with("image/") {
447        return Err(kcode_intelligence_router::Error::invalid(
448            "image references must use an image content type",
449        ));
450    }
451    kcode_intelligence_router::Media::new(
452        kcode_intelligence_router::MediaKind::Image,
453        bytes,
454        filename,
455        normalized,
456    )
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn speech_ktool_errors_keep_the_existing_public_failure_boundary() {
465        let malformed =
466            speech_ktool_error(kcode_speech_classification::KtoolError::InvalidArguments {
467                tool: kcode_speech_classification::IDENTIFY_TOOL,
468                source: serde_json::from_str::<Value>("{").unwrap_err(),
469            });
470        assert_eq!(
471            malformed.message,
472            "decoding kcode-speech-classification/identify arguments"
473        );
474
475        let validation = speech_ktool_error(kcode_speech_classification::KtoolError::Classifier(
476            kcode_speech_classification::Error::Validation {
477                field: "row.perceived_age".into(),
478                message: "must be positive".into(),
479            },
480        ));
481        assert_eq!(validation.message, "row.perceived_age: must be positive");
482
483        let storage = speech_ktool_error(kcode_speech_classification::KtoolError::Classifier(
484            kcode_speech_classification::Error::Storage("private detail".into()),
485        ));
486        assert_eq!(
487            storage.message,
488            "An unexpected Kennedy speaker-classification error occurred."
489        );
490    }
491}