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