Skip to main content

minco_plugin_feedback/
transcription.rs

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