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