gobby-wiki 0.2.0

Gobby wiki CLI shell
use crate::WikiError;
use crate::transcribe::{
    TranscriptSegment, TranscriptionClient, TranscriptionOutput, TranscriptionRequest,
};

pub(crate) fn translate_audio(
    request: &TranscriptionRequest<'_>,
    client: &dyn TranscriptionClient,
    target_lang: Option<&str>,
    language_hint: Option<&str>,
) -> Result<TranscriptionOutput, WikiError> {
    let target_lang = normalized_lang(target_lang).unwrap_or_else(|| "en".to_string());
    if is_english(&target_lang) {
        return match client.translate_to_english(request, language_hint) {
            Ok(output) => Ok(mark_english_translation(output, language_hint)),
            Err(error) => {
                eprintln!(
                    "Warning: translate_to_english failed; falling back to transcription plus segment translation: {error}"
                );
                let mut output = client.transcribe(request)?;
                output.translation_degraded = true;
                translate_transcription_segments(output, client, &target_lang, language_hint)
            }
        };
    }

    let output = client.transcribe(request)?;
    translate_transcription_segments(output, client, &target_lang, language_hint)
}

pub(crate) fn translate_transcription_segments(
    mut output: TranscriptionOutput,
    client: &dyn TranscriptionClient,
    target_lang: &str,
    language_hint: Option<&str>,
) -> Result<TranscriptionOutput, WikiError> {
    let source_lang = source_language(&output, language_hint)?;
    output.source_language = Some(source_lang.clone());

    if same_lang(&source_lang, target_lang) {
        output.translated = false;
        return Ok(output);
    }

    let translated_texts =
        translate_segment_texts(client, &output.segments, &source_lang, target_lang)?;
    for (segment, translated_text) in output.segments.iter_mut().zip(translated_texts) {
        segment.text = translated_text;
    }
    output.language = Some(target_lang.to_string());
    output.target_language = Some(target_lang.to_string());
    output.task = Some("translate".to_string());
    output.translated = true;
    Ok(output)
}

fn translate_segment_texts(
    client: &dyn TranscriptionClient,
    segments: &[TranscriptSegment],
    source_lang: &str,
    target_lang: &str,
) -> Result<Vec<String>, WikiError> {
    match client.translate_segments(segments, source_lang, target_lang) {
        Ok(first) if first.len() == segments.len() => return Ok(first),
        Ok(first) => warn_translation_batch_mismatch("first", first.len(), segments.len()),
        Err(error) => warn_translation_batch_error("first", &error),
    }

    let second = client.translate_segments(segments, source_lang, target_lang)?;
    if second.len() == segments.len() {
        return Ok(second);
    }
    warn_translation_batch_mismatch("second", second.len(), segments.len());

    segments
        .iter()
        .map(|segment| {
            client
                .translate_segments(std::slice::from_ref(segment), source_lang, target_lang)
                .and_then(|texts| {
                    texts.into_iter().next().ok_or_else(|| WikiError::Config {
                        detail: "segment translation returned no text".to_string(),
                    })
                })
        })
        .collect()
}

fn warn_translation_batch_mismatch(attempt: &str, actual_len: usize, expected_len: usize) {
    eprintln!(
        "Warning: {attempt} translation batch returned {actual_len} text(s) for {expected_len} segment(s); retrying or falling back to smaller batches"
    );
}

fn warn_translation_batch_error(attempt: &str, error: &WikiError) {
    eprintln!("Warning: {attempt} translation batch failed; retrying whole batch: {error}");
}

fn mark_english_translation(
    mut output: TranscriptionOutput,
    language_hint: Option<&str>,
) -> TranscriptionOutput {
    let source_lang = source_language(&output, language_hint).ok();
    output.source_language = source_lang;
    output.language = Some("en".to_string());
    output.target_language = Some("en".to_string());
    output.task = Some("translate".to_string());
    output.translated = true;
    output
}

fn source_language(
    output: &TranscriptionOutput,
    language_hint: Option<&str>,
) -> Result<String, WikiError> {
    normalized_lang(language_hint)
        .or_else(|| normalized_lang(output.source_language.as_deref()))
        .or_else(|| normalized_lang(output.language.as_deref()))
        .ok_or_else(|| WikiError::Config {
            detail: "transcription did not report a source language".to_string(),
        })
}

fn normalized_lang(language: Option<&str>) -> Option<String> {
    language
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| value.to_ascii_lowercase())
}

fn is_english(language: &str) -> bool {
    same_lang(language, "en") || language.starts_with("en-") || language.starts_with("en_")
}

fn same_lang(left: &str, right: &str) -> bool {
    left.eq_ignore_ascii_case(right)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::transcribe::{TranscriptSegment, TranscriptionOutput};
    use std::cell::RefCell;
    use std::path::Path;

    #[derive(Default)]
    struct FakeTranslationClient {
        transcript: RefCell<Option<TranscriptionOutput>>,
        english: RefCell<Option<Result<TranscriptionOutput, WikiError>>>,
        segment_batches: RefCell<Vec<(Vec<TranscriptSegment>, String, String)>>,
        segment_errors: RefCell<Vec<WikiError>>,
        translated_texts: RefCell<Vec<Vec<String>>>,
        calls: RefCell<Vec<&'static str>>,
    }

    impl FakeTranslationClient {
        fn with_transcript(output: TranscriptionOutput) -> Self {
            Self {
                transcript: RefCell::new(Some(output)),
                ..Self::default()
            }
        }

        fn with_english(output: Result<TranscriptionOutput, WikiError>) -> Self {
            Self {
                english: RefCell::new(Some(output)),
                ..Self::default()
            }
        }
    }

    impl TranscriptionClient for FakeTranslationClient {
        fn transcribe(
            &self,
            _request: &TranscriptionRequest<'_>,
        ) -> Result<TranscriptionOutput, WikiError> {
            self.calls.borrow_mut().push("transcribe");
            Ok(self.transcript.borrow_mut().take().unwrap())
        }

        fn translate_to_english(
            &self,
            _request: &TranscriptionRequest<'_>,
            _language_hint: Option<&str>,
        ) -> Result<TranscriptionOutput, WikiError> {
            self.calls.borrow_mut().push("translate_to_english");
            self.english.borrow_mut().take().unwrap()
        }

        fn translate_segments(
            &self,
            segments: &[TranscriptSegment],
            source_lang: &str,
            target_lang: &str,
        ) -> Result<Vec<String>, WikiError> {
            self.calls.borrow_mut().push("translate_segments");
            self.segment_batches.borrow_mut().push((
                segments.to_vec(),
                source_lang.to_string(),
                target_lang.to_string(),
            ));
            if !self.segment_errors.borrow().is_empty() {
                return Err(self.segment_errors.borrow_mut().remove(0));
            }
            Ok(self.translated_texts.borrow_mut().remove(0))
        }
    }

    #[test]
    fn precedence_and_segment_wise() {
        let request = request();
        let same_target = FakeTranslationClient::with_transcript(output("es", &["hola"]));

        let skipped = translate_audio(&request, &same_target, Some("es"), None).unwrap();

        assert!(!skipped.translated);
        assert!(same_target.segment_batches.borrow().is_empty());

        let client = FakeTranslationClient::with_transcript(output("fr", &["bonjour", "monde"]));
        client
            .translated_texts
            .borrow_mut()
            .push(vec!["guten tag".to_string(), "welt".to_string()]);

        let translated = translate_audio(&request, &client, Some("de"), None).unwrap();

        assert_eq!(translated.target_language.as_deref(), Some("de"));
        assert_eq!(translated.source_language.as_deref(), Some("fr"));
        assert!(translated.translated);
        assert_eq!(translated.segments[0].start_ms, 0);
        assert_eq!(translated.segments[0].end_ms, 1000);
        assert_eq!(translated.segments[0].text, "guten tag");
        assert_eq!(translated.segments[1].start_ms, 1000);
        assert_eq!(translated.segments[1].end_ms, 2000);
        assert_eq!(translated.segments[1].text, "welt");
    }

    #[test]
    fn translations_unsupported_fallback() {
        let request = request();
        let client = FakeTranslationClient::with_english(Err(WikiError::Config {
            detail: "audio translations endpoint unsupported".to_string(),
        }));
        *client.transcript.borrow_mut() = Some(output("es", &["hola"]));
        client
            .translated_texts
            .borrow_mut()
            .push(vec!["hello".to_string()]);

        let translated = translate_audio(&request, &client, None, None).unwrap();

        assert_eq!(
            client.calls.borrow().as_slice(),
            &["translate_to_english", "transcribe", "translate_segments"]
        );
        assert_eq!(translated.target_language.as_deref(), Some("en"));
        assert_eq!(translated.segments[0].text, "hello");
        assert!(translated.translation_degraded);
    }

    #[test]
    fn english_one_pass_vs_target_first() {
        let request = request();
        let english_client = FakeTranslationClient::with_english(Ok(output("es", &["hello"])));

        translate_audio(&request, &english_client, Some("en"), None).unwrap();

        assert_eq!(
            english_client.calls.borrow().as_slice(),
            &["translate_to_english"]
        );

        let non_english_client = FakeTranslationClient::with_transcript(output("es", &["hola"]));
        non_english_client
            .translated_texts
            .borrow_mut()
            .push(vec!["bonjour".to_string()]);
        translate_audio(&request, &non_english_client, Some("fr"), None).unwrap();

        assert_eq!(
            non_english_client.calls.borrow().first().copied(),
            Some("transcribe")
        );
        assert!(
            !non_english_client
                .calls
                .borrow()
                .contains(&"translate_to_english")
        );
    }

    #[test]
    fn batch_translation_errors_retry_batch_before_success() {
        let request = request();
        let client = FakeTranslationClient::with_transcript(output("fr", &["bonjour", "monde"]));
        client.segment_errors.borrow_mut().push(WikiError::Config {
            detail: "batch unavailable".to_string(),
        });
        client
            .translated_texts
            .borrow_mut()
            .push(vec!["hello".to_string(), "world".to_string()]);

        let translated = translate_audio(&request, &client, Some("de"), None).unwrap();

        assert_eq!(translated.segments[0].text, "hello");
        assert_eq!(translated.segments[1].text, "world");
        assert_eq!(client.segment_batches.borrow().len(), 2);
        assert!(
            client
                .segment_batches
                .borrow()
                .iter()
                .all(|(segments, _, _)| { segments.len() == 2 })
        );
    }

    fn request<'a>() -> TranscriptionRequest<'a> {
        TranscriptionRequest {
            file_name: "clip.webm",
            mime_type: Some("audio/webm"),
            asset_path: Path::new("clip.webm"),
            bytes: b"audio",
        }
    }

    fn output(source_lang: &str, texts: &[&str]) -> TranscriptionOutput {
        TranscriptionOutput {
            segments: texts
                .iter()
                .enumerate()
                .map(|(index, text)| TranscriptSegment {
                    start_ms: (index as u64) * 1000,
                    end_ms: (index as u64 + 1) * 1000,
                    text: (*text).to_string(),
                })
                .collect(),
            language: Some(source_lang.to_string()),
            model: Some("test-model".to_string()),
            source_language: Some(source_lang.to_string()),
            task: Some("transcribe".to_string()),
            target_language: None,
            translated: false,
            translation_degraded: false,
            partial: false,
            completed_ranges: Vec::new(),
            missing_ranges: Vec::new(),
        }
    }
}