Skip to main content

aurum_core/providers/
mod.rs

1//! Transcription provider abstraction.
2
3pub mod local;
4pub mod openrouter;
5
6use crate::audio::AudioInput;
7use crate::error::Result;
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11/// How a backend produces transcripts — affects timestamp trust and UX.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum BackendKind {
15    /// Dedicated ASR (e.g. whisper.cpp). Timestamps are engine-derived.
16    Asr,
17    /// Multimodal LLM asked to transcribe. Text may paraphrase; timestamps are unreliable.
18    LlmAssisted,
19}
20
21/// Options controlling a single transcription request.
22#[derive(Debug, Clone)]
23pub struct TranscriptionOptions {
24    /// Model name (local ggml name or remote model id).
25    pub model: String,
26    /// BCP-47 / ISO language code, or `"auto"`.
27    pub language: String,
28    /// Request segment-level timestamps when the provider supports them.
29    pub timestamps: bool,
30    /// Optional cooperative cancel flag (honoured by local whisper decode).
31    pub cancel: Option<crate::cancel::CancelFlag>,
32}
33
34impl Default for TranscriptionOptions {
35    fn default() -> Self {
36        Self {
37            model: crate::config::DEFAULT_LOCAL_MODEL.to_string(),
38            language: crate::config::DEFAULT_LANGUAGE.to_string(),
39            timestamps: false,
40            cancel: None,
41        }
42    }
43}
44
45impl TranscriptionOptions {
46    pub fn with_cancel(mut self, flag: crate::cancel::CancelFlag) -> Self {
47        self.cancel = Some(flag);
48        self
49    }
50}
51
52/// A single timed segment of transcript text.
53///
54/// Fields are **private** (JOE-1786). Construct with [`Segment::try_new`] (fail closed)
55/// or deserialize then [`Segment::validate`]. Prefer accessors over free mutation.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct Segment {
58    /// Start time in seconds.
59    start: f64,
60    /// End time in seconds.
61    end: f64,
62    text: String,
63}
64
65impl Segment {
66    /// Construct a segment, rejecting NaN/Inf, negatives, and inverted ranges (JOE-1781).
67    pub fn try_new(start: f64, end: f64, text: impl Into<String>) -> Result<Self> {
68        let s = Self {
69            start,
70            end,
71            text: text.into(),
72        };
73        s.validate()?;
74        Ok(s)
75    }
76
77    /// Construct without validation (trusted provider/postprocess paths and tests).
78    ///
79    /// Prefer [`Segment::try_new`] for host-facing construction. Callers that
80    /// skip validation must treat the segment as untrusted until [`Segment::validate`].
81    pub fn from_parts_unchecked(start: f64, end: f64, text: impl Into<String>) -> Self {
82        Self {
83            start,
84            end,
85            text: text.into(),
86        }
87    }
88
89    pub fn start(&self) -> f64 {
90        self.start
91    }
92
93    pub fn end(&self) -> f64 {
94        self.end
95    }
96
97    pub fn text(&self) -> &str {
98        &self.text
99    }
100
101    pub fn set_start(&mut self, start: f64) {
102        self.start = start;
103    }
104
105    pub fn set_end(&mut self, end: f64) {
106        self.end = end;
107    }
108
109    pub fn set_text(&mut self, text: impl Into<String>) {
110        self.text = text.into();
111    }
112
113    /// Validate timestamp finite-ness and ordering.
114    pub fn validate(&self) -> Result<()> {
115        if !self.start.is_finite() || !self.end.is_finite() {
116            return Err(crate::error::UserError::Other {
117                message: format!(
118                    "segment timestamps must be finite (start={}, end={})",
119                    self.start, self.end
120                ),
121            }
122            .into());
123        }
124        if self.start < 0.0 || self.end < 0.0 {
125            return Err(crate::error::UserError::Other {
126                message: format!(
127                    "segment timestamps must be non-negative (start={}, end={})",
128                    self.start, self.end
129                ),
130            }
131            .into());
132        }
133        if self.end < self.start {
134            return Err(crate::error::UserError::Other {
135                message: format!(
136                    "segment end before start (start={}, end={})",
137                    self.start, self.end
138                ),
139            }
140            .into());
141        }
142        Ok(())
143    }
144}
145
146/// Normalized result returned by every provider.
147///
148/// Fields are **private** (JOE-1809). Prefer builders
149/// [`TranscriptionResult::local`] / [`TranscriptionResult::openrouter`] and
150/// accessors. `Deserialize` is untrusted — use [`TranscriptionResult::try_from_dto`]
151/// or [`TranscriptionResult::validate_segments`] before relying on timings.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct TranscriptionResult {
154    text: String,
155    segments: Vec<Segment>,
156    language: Option<String>,
157    model: String,
158    provider: String,
159    duration_secs: f64,
160    /// Backend class — consumers should treat LLM timestamps as best-effort.
161    #[serde(default = "default_backend_kind")]
162    backend_kind: BackendKind,
163    /// Whether segment timestamps are considered reliable.
164    #[serde(default = "default_true")]
165    timestamps_reliable: bool,
166    /// Post-ASR cleanup style applied to [`Self::text`] (default: raw).
167    #[serde(default)]
168    cleanup_style: crate::cleanup::CleanupStyle,
169    /// Cleanup backend used, if any cleanup beyond raw was applied.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    cleanup_provider: Option<crate::cleanup::CleanupProviderKind>,
172    /// Pre-cleanup ASR text when cleanup rewrote [`Self::text`].
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    original_text: Option<String>,
175    /// Pre-cleanup ASR segments when cleanup rewrote or cleared timings.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    original_segments: Option<Vec<Segment>>,
178    /// Segment policy that was applied during cleanup (when not raw).
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    cleanup_segment_policy: Option<crate::cleanup::SegmentCleanupPolicy>,
181}
182
183fn default_backend_kind() -> BackendKind {
184    BackendKind::Asr
185}
186fn default_true() -> bool {
187    true
188}
189
190impl TranscriptionResult {
191    pub fn text(&self) -> &str {
192        &self.text
193    }
194
195    pub fn set_text(&mut self, text: impl Into<String>) {
196        self.text = text.into();
197    }
198
199    pub fn segments(&self) -> &[Segment] {
200        &self.segments
201    }
202
203    pub fn segments_mut(&mut self) -> &mut Vec<Segment> {
204        &mut self.segments
205    }
206
207    pub fn set_segments(&mut self, segments: Vec<Segment>) {
208        self.segments = segments;
209    }
210
211    pub fn language(&self) -> Option<&str> {
212        self.language.as_deref()
213    }
214
215    pub fn set_language(&mut self, language: Option<String>) {
216        self.language = language;
217    }
218
219    pub fn model(&self) -> &str {
220        &self.model
221    }
222
223    pub fn set_model(&mut self, model: impl Into<String>) {
224        self.model = model.into();
225    }
226
227    pub fn provider(&self) -> &str {
228        &self.provider
229    }
230
231    pub fn set_provider(&mut self, provider: impl Into<String>) {
232        self.provider = provider.into();
233    }
234
235    pub fn duration_secs(&self) -> f64 {
236        self.duration_secs
237    }
238
239    pub fn set_duration_secs(&mut self, duration_secs: f64) {
240        self.duration_secs = duration_secs;
241    }
242
243    pub fn backend_kind(&self) -> BackendKind {
244        self.backend_kind
245    }
246
247    pub fn set_backend_kind(&mut self, kind: BackendKind) {
248        self.backend_kind = kind;
249    }
250
251    pub fn timestamps_reliable(&self) -> bool {
252        self.timestamps_reliable
253    }
254
255    pub fn set_timestamps_reliable(&mut self, reliable: bool) {
256        self.timestamps_reliable = reliable;
257    }
258
259    pub fn cleanup_style(&self) -> crate::cleanup::CleanupStyle {
260        self.cleanup_style
261    }
262
263    pub fn set_cleanup_style(&mut self, style: crate::cleanup::CleanupStyle) {
264        self.cleanup_style = style;
265    }
266
267    pub fn cleanup_provider(&self) -> Option<crate::cleanup::CleanupProviderKind> {
268        self.cleanup_provider
269    }
270
271    pub fn set_cleanup_provider(&mut self, provider: Option<crate::cleanup::CleanupProviderKind>) {
272        self.cleanup_provider = provider;
273    }
274
275    pub fn original_text(&self) -> Option<&str> {
276        self.original_text.as_deref()
277    }
278
279    pub fn set_original_text(&mut self, text: Option<String>) {
280        self.original_text = text;
281    }
282
283    pub fn original_segments(&self) -> Option<&[Segment]> {
284        self.original_segments.as_deref()
285    }
286
287    pub fn set_original_segments(&mut self, segments: Option<Vec<Segment>>) {
288        self.original_segments = segments;
289    }
290
291    pub fn cleanup_segment_policy(&self) -> Option<crate::cleanup::SegmentCleanupPolicy> {
292        self.cleanup_segment_policy
293    }
294
295    pub fn set_cleanup_segment_policy(
296        &mut self,
297        policy: Option<crate::cleanup::SegmentCleanupPolicy>,
298    ) {
299        self.cleanup_segment_policy = policy;
300    }
301
302    /// Validate all segments (finite, ordered timestamps).
303    pub fn validate_segments(&self) -> Result<()> {
304        for (i, seg) in self.segments.iter().enumerate() {
305            if let Err(e) = seg.validate() {
306                return Err(crate::error::UserError::Other {
307                    message: format!("segment[{i}]: {e}"),
308                }
309                .into());
310            }
311        }
312        if !self.duration_secs.is_finite() || self.duration_secs < 0.0 {
313            return Err(crate::error::UserError::Other {
314                message: format!(
315                    "duration_secs must be finite and non-negative (got {})",
316                    self.duration_secs
317                ),
318            }
319            .into());
320        }
321        Ok(())
322    }
323
324    /// Build a domain result from a public DTO **with validation** (JOE-1809).
325    ///
326    /// Deserializing JSON into [`crate::dto::SttResultDto`] alone does not create
327    /// a trusted domain object — this path re-validates every segment and duration.
328    pub fn try_from_dto(dto: &crate::dto::SttResultDto) -> Result<Self> {
329        if dto.schema_version != crate::dto::STT_RESULT_SCHEMA_VERSION {
330            return Err(crate::error::UserError::Other {
331                message: format!(
332                    "unsupported STT DTO schema_version {} (expected {})",
333                    dto.schema_version,
334                    crate::dto::STT_RESULT_SCHEMA_VERSION
335                ),
336            }
337            .into());
338        }
339        let mut r = Self {
340            text: dto.text.clone(),
341            segments: dto.segments.clone(),
342            language: dto.language.clone(),
343            model: dto.model.clone(),
344            provider: dto.provider.clone(),
345            duration_secs: dto.duration_secs,
346            backend_kind: dto.backend_kind,
347            timestamps_reliable: dto.timestamps_reliable,
348            cleanup_style: dto.cleanup_style,
349            cleanup_provider: dto.cleanup_provider,
350            original_text: dto.original_text.clone(),
351            original_segments: dto.original_segments.clone(),
352            cleanup_segment_policy: dto.cleanup_segment_policy,
353        };
354        // LLM-assisted paths cannot claim reliable timestamps through DTO injection.
355        if matches!(r.backend_kind, BackendKind::LlmAssisted) {
356            r.timestamps_reliable = false;
357        }
358        r.validate_segments()?;
359        if let Some(ref segs) = r.original_segments {
360            for (i, seg) in segs.iter().enumerate() {
361                if let Err(e) = seg.validate() {
362                    return Err(crate::error::UserError::Other {
363                        message: format!("original_segments[{i}]: {e}"),
364                    }
365                    .into());
366                }
367            }
368        }
369        Ok(r)
370    }
371
372    pub fn local(
373        text: String,
374        segments: Vec<Segment>,
375        language: Option<String>,
376        model: String,
377        duration_secs: f64,
378    ) -> Self {
379        Self {
380            text,
381            segments,
382            language,
383            model,
384            provider: "local".into(),
385            duration_secs,
386            backend_kind: BackendKind::Asr,
387            timestamps_reliable: true,
388            cleanup_style: crate::cleanup::CleanupStyle::Raw,
389            cleanup_provider: None,
390            original_text: None,
391            original_segments: None,
392            cleanup_segment_policy: None,
393        }
394    }
395
396    /// Like [`Self::local`] but fail-closed when segments/duration are invalid (JOE-1781).
397    pub fn try_local(
398        text: String,
399        segments: Vec<Segment>,
400        language: Option<String>,
401        model: String,
402        duration_secs: f64,
403    ) -> Result<Self> {
404        let r = Self::local(text, segments, language, model, duration_secs);
405        r.validate_segments()?;
406        Ok(r)
407    }
408
409    pub fn openrouter(
410        text: String,
411        segments: Vec<Segment>,
412        language: Option<String>,
413        model: String,
414        duration_secs: f64,
415        _timestamps_requested: bool,
416    ) -> Self {
417        Self {
418            text,
419            segments,
420            language,
421            model,
422            provider: "openrouter".into(),
423            duration_secs,
424            backend_kind: BackendKind::LlmAssisted,
425            // LLM timestamps are never treated as reliable ASR timing.
426            timestamps_reliable: false,
427            cleanup_style: crate::cleanup::CleanupStyle::Raw,
428            cleanup_provider: None,
429            original_text: None,
430            original_segments: None,
431            cleanup_segment_policy: None,
432        }
433    }
434
435    /// Like [`Self::openrouter`] but fail-closed on invalid segments/duration (JOE-1781).
436    pub fn try_openrouter(
437        text: String,
438        segments: Vec<Segment>,
439        language: Option<String>,
440        model: String,
441        duration_secs: f64,
442        timestamps_requested: bool,
443    ) -> Result<Self> {
444        let r = Self::openrouter(
445            text,
446            segments,
447            language,
448            model,
449            duration_secs,
450            timestamps_requested,
451        );
452        r.validate_segments()?;
453        Ok(r)
454    }
455}
456
457/// Provider trait — the foundation for local and remote backends.
458#[async_trait]
459pub trait TranscriptionProvider: Send + Sync {
460    /// Human-readable provider name (e.g. `"local"`, `"openrouter"`).
461    fn name(&self) -> &'static str;
462
463    /// Backend classification.
464    fn backend_kind(&self) -> BackendKind;
465
466    /// Whether this provider can emit trustworthy media timestamps.
467    fn timestamps_reliable(&self) -> bool {
468        matches!(self.backend_kind(), BackendKind::Asr)
469    }
470
471    /// Transcribe audio according to `options`.
472    async fn transcribe(
473        &self,
474        input: &AudioInput,
475        options: &TranscriptionOptions,
476    ) -> Result<TranscriptionResult>;
477}
478
479pub use local::LocalWhisperProvider;
480pub use openrouter::{OpenRouterProvider, OpenRouterSttMode, SttPath};
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn segment_try_new_accepts_valid() {
488        let s = Segment::try_new(0.0, 1.5, "hello").unwrap();
489        assert_eq!(s.start, 0.0);
490        assert_eq!(s.end, 1.5);
491        assert_eq!(s.text, "hello");
492    }
493
494    #[test]
495    fn segment_try_new_rejects_nan() {
496        assert!(Segment::try_new(f64::NAN, 1.0, "x").is_err());
497        assert!(Segment::try_new(0.0, f64::INFINITY, "x").is_err());
498    }
499
500    #[test]
501    fn segment_try_new_rejects_negative_and_inverted() {
502        assert!(Segment::try_new(-0.1, 1.0, "x").is_err());
503        assert!(Segment::try_new(2.0, 1.0, "x").is_err());
504    }
505
506    #[test]
507    fn segment_validate_ok_on_zero_length() {
508        // Zero-duration is allowed (start == end).
509        Segment::try_new(1.0, 1.0, "").unwrap();
510    }
511
512    #[test]
513    fn try_local_rejects_nan_segment() {
514        let segs = vec![Segment::from_parts_unchecked(
515            f64::NAN,
516            1.0,
517            "x".to_string(),
518        )];
519        assert!(TranscriptionResult::try_local("x".into(), segs, None, "m".into(), 1.0).is_err());
520    }
521
522    #[test]
523    fn try_local_accepts_valid() {
524        let segs = vec![Segment::try_new(0.0, 0.5, "hi").unwrap()];
525        let r =
526            TranscriptionResult::try_local("hi".into(), segs, Some("en".into()), "m".into(), 1.0)
527                .unwrap();
528        assert_eq!(r.provider(), "local");
529    }
530
531    #[test]
532    fn try_from_dto_rejects_nan_segment() {
533        let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::local(
534            "x".into(),
535            vec![Segment::try_new(0.0, 1.0, "x").unwrap()],
536            None,
537            "m".into(),
538            1.0,
539        ));
540        dto.segments = vec![Segment::from_parts_unchecked(
541            f64::NAN,
542            1.0,
543            "x".to_string(),
544        )];
545        assert!(TranscriptionResult::try_from_dto(&dto).is_err());
546    }
547
548    #[test]
549    fn try_from_dto_forces_llm_timestamps_unreliable() {
550        let mut dto = crate::dto::SttResultDto::from_result(&TranscriptionResult::openrouter(
551            "hi".into(),
552            vec![Segment::try_new(0.0, 1.0, "hi").unwrap()],
553            None,
554            "m".into(),
555            1.0,
556            true,
557        ));
558        dto.timestamps_reliable = true; // injection attempt
559        let r = TranscriptionResult::try_from_dto(&dto).unwrap();
560        assert!(!r.timestamps_reliable());
561        assert_eq!(r.backend_kind(), BackendKind::LlmAssisted);
562    }
563}