Skip to main content

aurum_core/
dto.rs

1//! Versioned external JSON DTOs (JOE-1614).
2//!
3//! In-memory domain types are converted *to* these DTOs for CLI/embed JSON.
4//! Deserializing a DTO does **not** reconstruct native audio buffers.
5
6use crate::cleanup::{CleanupProviderKind, CleanupStyle, SegmentCleanupPolicy};
7use crate::error::TranscriptionError;
8use crate::providers::{BackendKind, Segment, TranscriptionResult};
9use serde::{Deserialize, Serialize};
10
11/// Current STT result schema version for `--emit-json` / library export.
12pub const STT_RESULT_SCHEMA_VERSION: u32 = 1;
13/// TTS metadata schema (PCM never included).
14pub const TTS_META_SCHEMA_VERSION: u32 = 1;
15/// Error envelope schema.
16pub const ERROR_SCHEMA_VERSION: u32 = 1;
17
18/// Versioned STT result DTO (public contract).
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct SttResultDto {
21    pub schema_version: u32,
22    pub text: String,
23    pub language: Option<String>,
24    pub model: String,
25    pub provider: String,
26    pub duration_secs: f64,
27    pub backend_kind: BackendKind,
28    pub timestamps_reliable: bool,
29    pub cleanup_style: CleanupStyle,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub cleanup_provider: Option<CleanupProviderKind>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub original_text: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub original_segments: Option<Vec<Segment>>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub cleanup_segment_policy: Option<SegmentCleanupPolicy>,
38    pub segments: Vec<Segment>,
39    /// Normalization warnings (empty when none).
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub normalization_warnings: Vec<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub request_id: Option<u64>,
44}
45
46impl SttResultDto {
47    pub fn from_result(result: &TranscriptionResult) -> Self {
48        Self::from_result_with_warnings(result, &[])
49    }
50
51    pub fn from_result_with_warnings(result: &TranscriptionResult, warnings: &[String]) -> Self {
52        Self {
53            schema_version: STT_RESULT_SCHEMA_VERSION,
54            text: result.text().to_string(),
55            language: result.language().map(|s| s.to_string()),
56            model: result.model().to_string(),
57            provider: result.provider().to_string(),
58            duration_secs: finite_or_zero(result.duration_secs()),
59            backend_kind: result.backend_kind(),
60            timestamps_reliable: result.timestamps_reliable(),
61            cleanup_style: result.cleanup_style(),
62            cleanup_provider: result.cleanup_provider(),
63            original_text: result.original_text().map(|s| s.to_string()),
64            original_segments: result.original_segments().map(|s| s.to_vec()),
65            cleanup_segment_policy: result.cleanup_segment_policy(),
66            segments: result
67                .segments()
68                .iter()
69                .map(|s| {
70                    Segment::from_parts_unchecked(
71                        finite_or_zero(s.start()),
72                        finite_or_zero(s.end()),
73                        s.text().to_string(),
74                    )
75                })
76                .collect(),
77            normalization_warnings: warnings.to_vec(),
78            request_id: None,
79        }
80    }
81
82    /// Convert this DTO into a validated domain [`TranscriptionResult`] (JOE-1809).
83    pub fn into_domain(self) -> crate::error::Result<TranscriptionResult> {
84        TranscriptionResult::try_from_dto(&self)
85    }
86
87    /// Pretty JSON that never emits NaN/Inf.
88    pub fn to_json_pretty(&self) -> Result<String, TranscriptionError> {
89        serde_json::to_string_pretty(self)
90            .map_err(|e| TranscriptionError::internal(format!("STT DTO serialize failed: {e}")))
91    }
92}
93
94/// TTS metadata DTO — **no PCM bytes**.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct TtsMetaDto {
97    pub schema_version: u32,
98    pub sample_rate_hz: u32,
99    pub channels: u16,
100    pub backend_kind: String,
101    pub provider: String,
102    pub model: String,
103    pub voice: String,
104    pub language: String,
105    pub duration_ms: u64,
106    pub text_chars: usize,
107    pub text_truncated: bool,
108    pub chunk_count: usize,
109    pub synthesized_chars: usize,
110    /// Adapter id (JOE-1576); omitted when unknown.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub adapter: Option<String>,
113    /// Trust mode: builtin | verified | local_unverified.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub trust: Option<String>,
116    /// Provenance: builtin | custom | local_pack.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub provenance: Option<String>,
119}
120
121#[cfg(feature = "tts")]
122impl TtsMetaDto {
123    pub fn from_result(r: &crate::tts::SynthesisResult) -> Self {
124        Self {
125            schema_version: TTS_META_SCHEMA_VERSION,
126            sample_rate_hz: r.sample_rate_hz,
127            channels: r.channels,
128            backend_kind: r.backend_kind.as_str().into(),
129            provider: r.provider.clone(),
130            model: r.model.clone(),
131            voice: r.voice.clone(),
132            language: r.language.clone(),
133            duration_ms: r.duration_ms,
134            text_chars: r.text_chars,
135            text_truncated: r.text_truncated,
136            chunk_count: r.chunk_count,
137            synthesized_chars: r.synthesized_chars,
138            adapter: r.adapter.clone(),
139            trust: r.trust.clone(),
140            provenance: r.provenance.clone(),
141        }
142    }
143}
144
145/// Stable error envelope for JSON/CLI.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct ErrorDto {
148    pub schema_version: u32,
149    pub category: String,
150    pub retryable: bool,
151    pub message: String,
152    pub exit_code: i32,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub operation: Option<String>,
155}
156
157impl ErrorDto {
158    pub fn from_error(err: &TranscriptionError) -> Self {
159        Self {
160            schema_version: ERROR_SCHEMA_VERSION,
161            category: err.error_category().as_str().into(),
162            retryable: err.retryable(),
163            message: err.to_string(),
164            exit_code: err.exit_code(),
165            operation: None,
166        }
167    }
168
169    pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
170        self.operation = Some(operation.into());
171        self
172    }
173
174    /// Pretty JSON that never emits NaN/Inf (finite-only fields).
175    pub fn to_json_pretty(&self) -> Result<String, TranscriptionError> {
176        serde_json::to_string_pretty(self)
177            .map_err(|e| TranscriptionError::internal(format!("ErrorDto serialize failed: {e}")))
178    }
179
180    /// Compact single-line JSON for machine consumers.
181    pub fn to_json(&self) -> Result<String, TranscriptionError> {
182        serde_json::to_string(self)
183            .map_err(|e| TranscriptionError::internal(format!("ErrorDto serialize failed: {e}")))
184    }
185}
186
187fn finite_or_zero(v: f64) -> f64 {
188    if v.is_finite() {
189        v
190    } else {
191        0.0
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn stt_dto_roundtrip_fields() {
201        let r = TranscriptionResult::local(
202            "hi".into(),
203            vec![Segment::from_parts_unchecked(0.0, 1.0, "hi".to_string())],
204            Some("en".into()),
205            "base".into(),
206            1.0,
207        );
208        let dto = SttResultDto::from_result(&r);
209        let json = dto.to_json_pretty().unwrap();
210        assert!(json.contains("schema_version"));
211        assert!(!json.contains("NaN"));
212        let back: SttResultDto = serde_json::from_str(&json).unwrap();
213        assert_eq!(back.text, "hi");
214        assert_eq!(back.schema_version, 1);
215    }
216
217    #[test]
218    fn nan_duration_sanitized() {
219        let mut r = TranscriptionResult::local("x".into(), vec![], None, "m".into(), f64::NAN);
220        r.set_duration_secs(f64::NAN);
221        let dto = SttResultDto::from_result(&r);
222        assert_eq!(dto.duration_secs, 0.0);
223    }
224
225    #[test]
226    fn error_dto_json_has_schema_and_exit_code() {
227        let err = crate::error::UserError::MissingApiKey.into();
228        let dto = ErrorDto::from_error(&err);
229        let json = dto.to_json_pretty().unwrap();
230        assert!(json.contains("schema_version"));
231        assert!(json.contains("exit_code"));
232        assert!(json.contains("category"));
233        assert_eq!(dto.schema_version, ERROR_SCHEMA_VERSION);
234        assert_eq!(dto.exit_code, err.exit_code());
235    }
236
237    #[cfg(feature = "tts")]
238    #[test]
239    fn tts_meta_local_and_remote_backend_kind() {
240        use crate::tts::{BackendKind, SynthesisResult};
241
242        let local = SynthesisResult {
243            pcm_i16_mono: vec![1, 2, 3],
244            sample_rate_hz: 24_000,
245            channels: 1,
246            backend_kind: BackendKind::Local,
247            provider: "local".into(),
248            model: "kitten-nano-int8".into(),
249            voice: "Luna".into(),
250            language: "en".into(),
251            duration_ms: 1,
252            text_chars: 1,
253            text_truncated: false,
254            chunk_count: 1,
255            synthesized_chars: 1,
256            adapter: None,
257            trust: None,
258            provenance: None,
259        };
260        let dto = TtsMetaDto::from_result(&local);
261        assert_eq!(dto.schema_version, TTS_META_SCHEMA_VERSION);
262        assert_eq!(dto.backend_kind, "local");
263        // PCM never in DTO JSON.
264        let json = serde_json::to_string(&dto).unwrap();
265        assert!(!json.contains("pcm"));
266        assert_eq!(dto.schema_version, 1);
267
268        let mut remote = local.clone();
269        remote.backend_kind = BackendKind::Remote;
270        remote.provider = "openai".into();
271        let dto_r = TtsMetaDto::from_result(&remote);
272        assert_eq!(dto_r.backend_kind, "remote");
273        // Adding Remote does not bump schema_version (backend_kind was already a string).
274        assert_eq!(dto_r.schema_version, 1);
275    }
276}