Skip to main content

minco_interaction/
transcription.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::{fmt, sync::Arc};
4
5#[derive(Clone, PartialEq, Eq)]
6pub struct TranscriptionRequest {
7    pub bytes: Vec<u8>,
8    pub file_name: String,
9    pub content_type: String,
10    pub language: Option<String>,
11    pub prompt: Option<String>,
12}
13
14impl fmt::Debug for TranscriptionRequest {
15    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
16        formatter
17            .debug_struct("TranscriptionRequest")
18            .field("audio", &"[REDACTED]")
19            .field("size_bytes", &self.bytes.len())
20            .field("file_name", &"[REDACTED]")
21            .field("content_type", &self.content_type)
22            .field("language", &self.language)
23            .field("prompt", &self.prompt.as_ref().map(|_| "[REDACTED]"))
24            .finish()
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TranscriptionResult {
30    pub text: String,
31    pub provider: String,
32    pub model: String,
33}
34
35pub type AudioInput = TranscriptionRequest;
36pub type Transcript = TranscriptionResult;
37
38#[async_trait]
39pub trait Transcriber: Send + Sync + fmt::Debug {
40    async fn transcribe(
41        &self,
42        request: TranscriptionRequest,
43    ) -> Result<TranscriptionResult, TranscriptionError>;
44}
45
46#[derive(Clone)]
47pub struct TranscriptionService(pub Arc<dyn Transcriber>);
48
49impl fmt::Debug for TranscriptionService {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        formatter.debug_tuple("TranscriptionService").finish()
52    }
53}
54
55impl TranscriptionService {
56    pub fn new(transcriber: Arc<dyn Transcriber>) -> Self {
57        Self(transcriber)
58    }
59
60    pub async fn transcribe(
61        &self,
62        request: TranscriptionRequest,
63    ) -> Result<TranscriptionResult, TranscriptionError> {
64        self.0.transcribe(request).await
65    }
66}
67
68#[derive(Debug, Default)]
69pub struct DisabledTranscriber;
70
71#[async_trait]
72impl Transcriber for DisabledTranscriber {
73    async fn transcribe(
74        &self,
75        _request: TranscriptionRequest,
76    ) -> Result<TranscriptionResult, TranscriptionError> {
77        Err(TranscriptionError::NotConfigured)
78    }
79}
80
81#[cfg(feature = "openai-transcription")]
82#[derive(Clone)]
83pub struct OpenAiTranscriber {
84    client: reqwest::Client,
85    api_key: Arc<str>,
86    endpoint: Arc<str>,
87    model: Arc<str>,
88}
89
90#[cfg(feature = "openai-transcription")]
91impl fmt::Debug for OpenAiTranscriber {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        formatter
94            .debug_struct("OpenAiTranscriber")
95            .field("client", &self.client)
96            .field("endpoint", &"[CONFIGURED]")
97            .field("model", &self.model)
98            .field("api_key", &"[REDACTED]")
99            .finish()
100    }
101}
102
103#[cfg(feature = "openai-transcription")]
104impl OpenAiTranscriber {
105    pub fn new(api_key: impl Into<String>) -> Result<Self, TranscriptionError> {
106        Self::with_options(
107            api_key,
108            "https://api.openai.com/v1/audio/transcriptions",
109            "gpt-4o-mini-transcribe",
110        )
111    }
112
113    pub fn from_env(variable: &str) -> Result<Self, TranscriptionError> {
114        let api_key = std::env::var(variable)
115            .map_err(|_| TranscriptionError::MissingApiKey(variable.to_owned()))?;
116        Self::new(api_key)
117    }
118
119    pub fn with_options(
120        api_key: impl Into<String>,
121        endpoint: impl Into<String>,
122        model: impl Into<String>,
123    ) -> Result<Self, TranscriptionError> {
124        let api_key = api_key.into();
125        if api_key.trim().is_empty() {
126            return Err(TranscriptionError::MissingApiKey(
127                "configured OpenAI API key".into(),
128            ));
129        }
130        let client = reqwest::Client::builder()
131            .timeout(std::time::Duration::from_mins(1))
132            .build()
133            .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
134        Ok(Self {
135            client,
136            api_key: api_key.into(),
137            endpoint: endpoint.into().into(),
138            model: model.into().into(),
139        })
140    }
141}
142
143#[cfg(feature = "openai-transcription")]
144#[derive(Debug, Deserialize)]
145struct OpenAiTranscriptionResponse {
146    text: String,
147}
148
149#[cfg(feature = "openai-transcription")]
150#[async_trait]
151impl Transcriber for OpenAiTranscriber {
152    async fn transcribe(
153        &self,
154        request: TranscriptionRequest,
155    ) -> Result<TranscriptionResult, TranscriptionError> {
156        validate_audio(&request)?;
157        let part = reqwest::multipart::Part::bytes(request.bytes)
158            .file_name(request.file_name)
159            .mime_str(&request.content_type)
160            .map_err(|error| TranscriptionError::InvalidAudio(error.to_string()))?;
161        let mut form = reqwest::multipart::Form::new()
162            .text("model", self.model.to_string())
163            .part("file", part);
164        if let Some(language) = request.language.filter(|value| !value.trim().is_empty()) {
165            form = form.text("language", language);
166        }
167        if let Some(prompt) = request.prompt.filter(|value| !value.trim().is_empty()) {
168            form = form.text("prompt", prompt);
169        }
170        let response = self
171            .client
172            .post(self.endpoint.as_ref())
173            .bearer_auth(self.api_key.as_ref())
174            .multipart(form)
175            .send()
176            .await
177            .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
178        let status = response.status();
179        if !status.is_success() {
180            let detail = response.text().await.unwrap_or_default();
181            return Err(TranscriptionError::Provider(format!(
182                "OpenAI transcription returned {status}: {}",
183                truncate_text(&detail, 500)
184            )));
185        }
186        let payload = response
187            .json::<OpenAiTranscriptionResponse>()
188            .await
189            .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
190        if payload.text.trim().is_empty() {
191            return Err(TranscriptionError::Provider(
192                "transcription provider returned empty text".into(),
193            ));
194        }
195        Ok(TranscriptionResult {
196            text: payload.text,
197            provider: "openai".into(),
198            model: self.model.to_string(),
199        })
200    }
201}
202
203#[cfg(feature = "command-transcription")]
204#[derive(Clone)]
205pub struct CommandTranscriber {
206    program: Arc<std::path::PathBuf>,
207    arguments: Arc<Vec<String>>,
208    provider: Arc<str>,
209    model: Arc<str>,
210    timeout: std::time::Duration,
211}
212
213#[cfg(feature = "command-transcription")]
214impl fmt::Debug for CommandTranscriber {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        formatter
217            .debug_struct("CommandTranscriber")
218            .field("program", &self.program)
219            .field("argument_count", &self.arguments.len())
220            .field("provider", &self.provider)
221            .field("model", &self.model)
222            .field("timeout", &self.timeout)
223            .finish()
224    }
225}
226
227#[cfg(feature = "command-transcription")]
228impl CommandTranscriber {
229    #[must_use]
230    pub fn new(program: impl Into<std::path::PathBuf>) -> Self {
231        Self {
232            program: Arc::new(program.into()),
233            arguments: Arc::new(Vec::new()),
234            provider: "command".into(),
235            model: "local".into(),
236            timeout: std::time::Duration::from_mins(2),
237        }
238    }
239
240    #[must_use]
241    pub fn with_arguments<I, S>(mut self, arguments: I) -> Self
242    where
243        I: IntoIterator<Item = S>,
244        S: Into<String>,
245    {
246        self.arguments = Arc::new(arguments.into_iter().map(Into::into).collect());
247        self
248    }
249
250    #[must_use]
251    pub fn with_identity(mut self, provider: impl Into<String>, model: impl Into<String>) -> Self {
252        self.provider = provider.into().into();
253        self.model = model.into().into();
254        self
255    }
256
257    #[must_use]
258    pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
259        self.timeout = timeout;
260        self
261    }
262
263    fn rendered_arguments(&self, input: &std::path::Path) -> Vec<std::ffi::OsString> {
264        let input = input.as_os_str().to_os_string();
265        let input_text = input.to_string_lossy();
266        let mut replaced = false;
267        let mut arguments = self
268            .arguments
269            .iter()
270            .map(|argument| {
271                if argument.contains("{input}") {
272                    replaced = true;
273                    std::ffi::OsString::from(argument.replace("{input}", &input_text))
274                } else {
275                    std::ffi::OsString::from(argument)
276                }
277            })
278            .collect::<Vec<_>>();
279        if !replaced {
280            arguments.push(input);
281        }
282        arguments
283    }
284}
285
286#[cfg(feature = "command-transcription")]
287#[async_trait]
288impl Transcriber for CommandTranscriber {
289    async fn transcribe(
290        &self,
291        request: TranscriptionRequest,
292    ) -> Result<TranscriptionResult, TranscriptionError> {
293        validate_audio(&request)?;
294        if self.timeout.is_zero() {
295            return Err(TranscriptionError::Provider(
296                "command transcription timeout must be greater than zero".into(),
297            ));
298        }
299        let directory =
300            tempfile::tempdir().map_err(|error| TranscriptionError::Provider(error.to_string()))?;
301        let extension = safe_audio_extension(&request.file_name, &request.content_type);
302        let input = directory
303            .path()
304            .join(format!("interaction-audio.{extension}"));
305        tokio::fs::write(&input, request.bytes)
306            .await
307            .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
308        let mut command = tokio::process::Command::new(self.program.as_ref());
309        command
310            .args(self.rendered_arguments(&input))
311            .kill_on_drop(true)
312            .stdin(std::process::Stdio::null())
313            .stdout(std::process::Stdio::piped())
314            .stderr(std::process::Stdio::piped());
315        let output = tokio::time::timeout(self.timeout, command.output())
316            .await
317            .map_err(|_| {
318                TranscriptionError::Provider(format!(
319                    "transcription command timed out after {} seconds",
320                    self.timeout.as_secs()
321                ))
322            })?
323            .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
324        if !output.status.success() {
325            let detail = String::from_utf8_lossy(&output.stderr);
326            return Err(TranscriptionError::Provider(format!(
327                "transcription command exited with {}: {}",
328                output.status,
329                truncate_text(&detail, 500)
330            )));
331        }
332        let text = String::from_utf8(output.stdout)
333            .map_err(|error| TranscriptionError::Provider(error.to_string()))?;
334        let text = text.trim().to_owned();
335        if text.is_empty() {
336            return Err(TranscriptionError::Provider(
337                "transcription command returned empty stdout".into(),
338            ));
339        }
340        Ok(TranscriptionResult {
341            text,
342            provider: self.provider.to_string(),
343            model: self.model.to_string(),
344        })
345    }
346}
347
348#[cfg(any(feature = "openai-transcription", feature = "command-transcription"))]
349fn validate_audio(request: &TranscriptionRequest) -> Result<(), TranscriptionError> {
350    if request.bytes.is_empty() {
351        Err(TranscriptionError::InvalidAudio("audio is empty".into()))
352    } else {
353        Ok(())
354    }
355}
356
357#[cfg(feature = "command-transcription")]
358fn safe_audio_extension(file_name: &str, content_type: &str) -> String {
359    let extension = std::path::Path::new(file_name)
360        .extension()
361        .and_then(std::ffi::OsStr::to_str)
362        .filter(|value| {
363            !value.is_empty()
364                && value.len() <= 10
365                && value.bytes().all(|byte| byte.is_ascii_alphanumeric())
366        });
367    extension.map_or_else(
368        || match content_type.to_ascii_lowercase().as_str() {
369            "audio/mpeg" | "audio/mp3" => "mp3".into(),
370            "audio/mp4" | "audio/x-m4a" => "m4a".into(),
371            "audio/ogg" => "ogg".into(),
372            "audio/wav" | "audio/x-wav" => "wav".into(),
373            _ => "webm".into(),
374        },
375        str::to_ascii_lowercase,
376    )
377}
378
379#[cfg(any(feature = "openai-transcription", feature = "command-transcription"))]
380fn truncate_text(value: &str, maximum: usize) -> String {
381    value.chars().take(maximum).collect()
382}
383
384#[derive(Debug, thiserror::Error)]
385pub enum TranscriptionError {
386    #[error("voice transcription provider is not configured")]
387    NotConfigured,
388    #[error("missing transcription API key: {0}")]
389    MissingApiKey(String),
390    #[error("invalid audio: {0}")]
391    InvalidAudio(String),
392    #[error("transcription provider failed: {0}")]
393    Provider(String),
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[tokio::test]
401    async fn disabled_transcriber_fails_explicitly_and_debug_hides_audio() {
402        let request = TranscriptionRequest {
403            bytes: b"secret-audio".to_vec(),
404            file_name: "voice.webm".into(),
405            content_type: "audio/webm".into(),
406            language: None,
407            prompt: Some("private prompt".into()),
408        };
409        let debug = format!("{request:?}");
410        assert!(!debug.contains("secret-audio"));
411        assert!(!debug.contains("private prompt"));
412        assert!(matches!(
413            DisabledTranscriber.transcribe(request).await,
414            Err(TranscriptionError::NotConfigured)
415        ));
416    }
417
418    #[cfg(feature = "command-transcription")]
419    #[test]
420    fn command_arguments_are_direct_and_bounded() {
421        let input = std::path::Path::new("/tmp/example.webm");
422        let arguments = CommandTranscriber::new("whisper")
423            .with_arguments(["--file", concat!("{", "input", "}")])
424            .rendered_arguments(input);
425        assert_eq!(arguments[1], std::ffi::OsString::from("/tmp/example.webm"));
426        assert_eq!(safe_audio_extension("voice", "audio/ogg"), "ogg");
427    }
428}