Skip to main content

kcode_kennedy_session_services/
lib.rs

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