Skip to main content

native_whisperx/translation/
result.rs

1//! Immutable translated-result contracts and execution.
2
3use audio_analysis_transcription::TranscriptionPipelineResponse;
4use serde::Serialize;
5use std::{path::PathBuf, time::Instant};
6
7use super::{CuratedLanguage, TranslationLeg, TranslationPlan, TranslationPlanProvenance};
8use crate::workflow::{
9    CancellationHandle, FiniteCancellation, NoopTranscriptionProgressObserver,
10    TranscriptionProgressEvent, TranscriptionProgressObserver, TranscriptionProgressTask,
11};
12
13/// A boundary that executes one planned model leg for one transcript segment.
14///
15/// Implementations may resolve and reuse models however they choose. The
16/// operation supplies legs in plan order and skips segments whose text is
17/// empty after trimming.
18pub trait SegmentTranslationProvider {
19    /// Stable provider identifier used by the Transcription Progress Stream.
20    fn provider_id(&self) -> &str {
21        "segment-translation-provider"
22    }
23
24    /// Translates one non-empty segment using the supplied planned model leg.
25    fn translate_segment(
26        &mut self,
27        leg: &TranslationLeg,
28        text: &str,
29    ) -> Result<String, TranslationModelError>;
30}
31
32/// Typed result of translating an immutable transcription with cooperative control.
33#[derive(Debug)]
34pub enum TranslatedTranscriptionOutcome {
35    /// Every direct or Pivot Translation leg completed in plan order.
36    Completed(TranslatedTranscriptionResult),
37    /// Translation stopped at a safe leg or segment boundary.
38    Cancelled(FiniteCancellation),
39}
40
41/// A typed failure reported by a [`SegmentTranslationProvider`].
42#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
43#[error("{message}")]
44pub struct TranslationModelError {
45    message: String,
46}
47
48impl TranslationModelError {
49    /// Creates a provider failure with a user-facing explanation.
50    pub fn new(message: impl Into<String>) -> Self {
51        Self {
52            message: message.into(),
53        }
54    }
55
56    /// Returns the provider's failure explanation.
57    pub fn message(&self) -> &str {
58        &self.message
59    }
60}
61
62/// A translated transcript kept separate from its source pipeline response.
63///
64/// Ordered legs are retained as both execution and model provenance: each leg
65/// records its source, target, and canonical model ID.
66#[derive(Debug, Clone, PartialEq, Serialize)]
67#[serde(rename_all = "camelCase")]
68pub struct TranslatedTranscriptionResult {
69    transcript: text_transcripts::TranscriptionContract,
70    source_language: CuratedLanguage,
71    target_language: CuratedLanguage,
72    provenance: TranslationPlanProvenance,
73    legs: Vec<TranslationLeg>,
74}
75
76impl TranslatedTranscriptionResult {
77    /// Returns the separately owned target-language transcript.
78    pub fn transcript(&self) -> &text_transcripts::TranscriptionContract {
79        &self.transcript
80    }
81
82    /// Consumes the result and returns its target-language transcript.
83    pub fn into_transcript(self) -> text_transcripts::TranscriptionContract {
84        self.transcript
85    }
86
87    /// Returns the original language consumed by the plan.
88    pub const fn source_language(&self) -> CuratedLanguage {
89        self.source_language
90    }
91
92    /// Returns the result language produced by the plan.
93    pub const fn target_language(&self) -> CuratedLanguage {
94        self.target_language
95    }
96
97    /// Returns whether the result came from a direct or Pivot Translation.
98    pub const fn provenance(&self) -> TranslationPlanProvenance {
99        self.provenance
100    }
101
102    /// Returns executed legs in order, including each model ID.
103    pub fn legs(&self) -> &[TranslationLeg] {
104        &self.legs
105    }
106}
107
108/// A typed failure while executing an already validated translation plan.
109#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
110pub enum TranslationError {
111    /// A model leg failed while translating a source segment.
112    #[error(
113        "translation leg {leg_index} ({leg_source}->{leg_target}, model `{model_id}`) failed for segment {segment_index}: {source}"
114    )]
115    LegFailed {
116        /// Zero-based position in the ordered plan.
117        leg_index: usize,
118        /// Stable source transcript segment index.
119        segment_index: u64,
120        /// Source language of the failed leg.
121        leg_source: CuratedLanguage,
122        /// Target language of the failed leg.
123        leg_target: CuratedLanguage,
124        /// Canonical model ID of the failed leg.
125        model_id: String,
126        /// Provider failure that stopped execution.
127        #[source]
128        source: TranslationModelError,
129    },
130}
131
132/// Executes a plan without taking ownership of or mutating the source result.
133///
134/// Segment boundaries, timings, speaker facts, confidence, attributes, and
135/// transcript metadata are copied to the target result. Source-language word
136/// and character alignments are omitted because they do not describe the
137/// translated text.
138pub fn translate_transcription(
139    source: &TranscriptionPipelineResponse,
140    plan: &TranslationPlan,
141    provider: &mut dyn SegmentTranslationProvider,
142) -> Result<TranslatedTranscriptionResult, TranslationError> {
143    let mut observer = NoopTranscriptionProgressObserver;
144    let cancellation = CancellationHandle::new();
145    match translate_transcription_with_control(
146        source,
147        plan,
148        provider,
149        0,
150        PathBuf::from("<translation>"),
151        &mut observer,
152        &cancellation,
153    )? {
154        TranslatedTranscriptionOutcome::Completed(result) => Ok(result),
155        TranslatedTranscriptionOutcome::Cancelled(_) => {
156            unreachable!("the compatibility translation entry point uses an uncancelled handle")
157        }
158    }
159}
160
161/// Executes direct or Pivot Translation with ordered leg progress and cancellation.
162pub fn translate_transcription_with_control(
163    source: &TranscriptionPipelineResponse,
164    plan: &TranslationPlan,
165    provider: &mut dyn SegmentTranslationProvider,
166    file_index: usize,
167    input: PathBuf,
168    observer: &mut dyn TranscriptionProgressObserver,
169    cancellation: &CancellationHandle,
170) -> Result<TranslatedTranscriptionOutcome, TranslationError> {
171    let started = Instant::now();
172    if cancellation.is_cancelled() {
173        return Ok(cancelled_translation(file_index, input, observer, started));
174    }
175
176    let mut transcript = source.transcript.clone();
177    observer.observe(TranscriptionProgressEvent::TaskStart {
178        file_index,
179        task: TranscriptionProgressTask::Translation,
180    });
181
182    for (leg_index, leg) in plan.legs().iter().enumerate() {
183        if cancellation.is_cancelled() {
184            return Ok(cancelled_translation(file_index, input, observer, started));
185        }
186        let leg_started = Instant::now();
187        let provider_id = provider.provider_id().to_string();
188        observer.observe(TranscriptionProgressEvent::TranslationLegStart {
189            file_index,
190            leg_index,
191            total_legs: plan.legs().len(),
192            provenance: plan.provenance(),
193            source: leg.source(),
194            target: leg.target(),
195            provider: provider_id.clone(),
196            model_id: leg.model_id().to_string(),
197        });
198        if cancellation.is_cancelled() {
199            return Ok(cancelled_translation(file_index, input, observer, started));
200        }
201        for segment in &mut transcript.segments {
202            if cancellation.is_cancelled() {
203                return Ok(cancelled_translation(file_index, input, observer, started));
204            }
205            let source_text = segment.text.trim();
206            if source_text.is_empty() {
207                continue;
208            }
209            segment.text = match provider.translate_segment(leg, source_text) {
210                Ok(translated) => translated,
211                Err(source) => {
212                    let error = TranslationError::LegFailed {
213                        leg_index,
214                        segment_index: segment.index,
215                        leg_source: leg.source(),
216                        leg_target: leg.target(),
217                        model_id: leg.model_id().to_string(),
218                        source,
219                    };
220                    observer.observe(TranscriptionProgressEvent::Failure {
221                        file_index,
222                        input: input.clone(),
223                        task: Some(TranscriptionProgressTask::Translation),
224                        duration_seconds: started.elapsed().as_secs_f64(),
225                        message: error.to_string(),
226                    });
227                    return Err(error);
228                }
229            };
230        }
231        if cancellation.is_cancelled() {
232            return Ok(cancelled_translation(file_index, input, observer, started));
233        }
234        observer.observe(TranscriptionProgressEvent::TranslationLegEnd {
235            file_index,
236            leg_index,
237            total_legs: plan.legs().len(),
238            provenance: plan.provenance(),
239            source: leg.source(),
240            target: leg.target(),
241            provider: provider_id,
242            model_id: leg.model_id().to_string(),
243            duration_seconds: leg_started.elapsed().as_secs_f64(),
244        });
245        if cancellation.is_cancelled() {
246            return Ok(cancelled_translation(file_index, input, observer, started));
247        }
248    }
249
250    let target_language = plan.target().code().to_string();
251    for segment in &mut transcript.segments {
252        segment.language = Some(target_language.clone());
253        segment.words.clear();
254        segment.chars.clear();
255    }
256    transcript.language = Some(target_language);
257    transcript.text = Some(transcript.joined_text());
258
259    let result = TranslatedTranscriptionResult {
260        transcript,
261        source_language: plan.source(),
262        target_language: plan.target(),
263        provenance: plan.provenance(),
264        legs: plan.legs().to_vec(),
265    };
266    observer.observe(TranscriptionProgressEvent::TaskEnd {
267        file_index,
268        task: TranscriptionProgressTask::Translation,
269        duration_seconds: started.elapsed().as_secs_f64(),
270    });
271    Ok(TranslatedTranscriptionOutcome::Completed(result))
272}
273
274fn cancelled_translation(
275    file_index: usize,
276    input: PathBuf,
277    observer: &mut dyn TranscriptionProgressObserver,
278    started: Instant,
279) -> TranslatedTranscriptionOutcome {
280    let cancellation = FiniteCancellation::new(
281        file_index,
282        input.clone(),
283        Some(TranscriptionProgressTask::Translation),
284    );
285    observer.observe(TranscriptionProgressEvent::Cancelled {
286        file_index,
287        input,
288        task: Some(TranscriptionProgressTask::Translation),
289        duration_seconds: started.elapsed().as_secs_f64(),
290    });
291    TranslatedTranscriptionOutcome::Cancelled(cancellation)
292}