diff --git a/crates/arr-subs/src/openai.rs b/crates/arr-subs/src/openai.rs index 82e096c..cc5b17c 100644 --- a/crates/arr-subs/src/openai.rs +++ b/crates/arr-subs/src/openai.rs @@ -25,7 +25,10 @@ use serde::{Deserialize, Serialize}; use arr_core::Language; -use crate::translate::{Backend, BackendId, Batch, Error, Result, TranslateFuture, TranslatedCue}; +use crate::translate::{ + strip_code_fence, system_prompt, Backend, BackendId, Batch, Error, Result, TranslateFuture, + TranslatedCue, +}; const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1/"; const BACKEND_NAME: &str = "openai"; @@ -277,69 +280,6 @@ fn truncate(body: &str) -> String { body.chars().take(MAX_ERROR_BODY).collect() } -/// Strip a ```` ```json ```` / ```` ``` ```` fence a model wrapped its reply -/// in despite being told not to. Cheaper than retrying the whole batch over a -/// habit some models never break. -fn strip_code_fence(content: &str) -> &str { - let trimmed = content.trim(); - let trimmed = trimmed - .strip_prefix("```json") - .or_else(|| trimmed.strip_prefix("```")) - .unwrap_or(trimmed); - trimmed.strip_suffix("```").unwrap_or(trimmed).trim() -} - -/// How a language reads in a prompt meant for an LLM, not a settings row. -fn language_name(language: &Language) -> String { - match language { - Language::PortuguesePortugal => "European Portuguese (pt-PT)".to_owned(), - Language::PortugueseBrazil => "Brazilian Portuguese (pt-BR)".to_owned(), - Language::PortugueseUnverified => "Portuguese".to_owned(), - Language::Other(tag) => tag.clone(), - } -} - -/// A concrete nudge against the one mistake DESIGN.md §15 calls out by name: -/// answering the wrong Portuguese. Generic advice ("respect the variant") -/// is easy for a model to nod at and ignore; naming the words it must not -/// reach for is not. -fn variant_note(target: &Language) -> Option<&'static str> { - match target { - Language::PortuguesePortugal => Some( - "This is continental European Portuguese, not Brazilian Portuguese: write \"tu\", \ - \"comboio\", \"casa de banho\" and European spelling and verb forms, never \ - Brazilian ones.", - ), - Language::PortugueseBrazil => Some( - "This is Brazilian Portuguese, not European Portuguese: write \"você\", \"trem\", \ - \"banheiro\" and Brazilian spelling and verb forms, never European ones.", - ), - _ => None, - } -} - -fn system_prompt(source: &Language, target: &Language) -> String { - let mut prompt = format!( - "You translate subtitle cues for a film or TV episode from {} into {}. Write natural, \ - spoken subtitle language a native speaker would actually use, not a stiff literal \ - translation.", - language_name(source), - language_name(target) - ); - if let Some(note) = variant_note(target) { - prompt.push(' '); - prompt.push_str(note); - } - prompt.push_str( - " Reply with nothing but a JSON array, one object per cue, each shaped \ - {\"number\": , \"text\": \"\"}, in the \ - order you received them. Every cue you were given must appear exactly once, under its \ - original number: never add, drop, merge or renumber a cue. No prose, no markdown code \ - fences, no explanation — JSON only.", - ); - prompt -} - #[derive(Serialize)] struct RequestCue<'a> { number: usize, @@ -386,8 +326,9 @@ struct ChatReplyMessage { mod tests { use arr_core::Language; - use super::{language_name, strip_code_fence, system_prompt, OpenAi}; + use super::OpenAi; use crate::translate::Backend; + use crate::translate::{language_name, strip_code_fence, system_prompt}; fn backend() -> OpenAi { OpenAi::new("gpt-4o-mini", super::OpenAiConfig { api_key: None }).expect("client builds") diff --git a/crates/arr-subs/src/translate.rs b/crates/arr-subs/src/translate.rs index 4114a90..50b29c2 100644 --- a/crates/arr-subs/src/translate.rs +++ b/crates/arr-subs/src/translate.rs @@ -153,6 +153,72 @@ pub trait Backend: fmt::Debug + Send + Sync { /// Result alias for translation. pub type Result = std::result::Result; +/// Strip a ```` ```json ```` / ```` ``` ```` fence a model wrapped its reply +/// in despite being told not to. Cheaper than retrying the whole batch over a +/// habit some models never break. +pub(crate) fn strip_code_fence(content: &str) -> &str { + let trimmed = content.trim(); + let trimmed = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .unwrap_or(trimmed); + trimmed.strip_suffix("```").unwrap_or(trimmed).trim() +} + +/// How a language reads in a prompt meant for an LLM, not a settings row. +pub(crate) fn language_name(language: &Language) -> String { + match language { + Language::PortuguesePortugal => "European Portuguese (pt-PT)".to_owned(), + Language::PortugueseBrazil => "Brazilian Portuguese (pt-BR)".to_owned(), + Language::PortugueseUnverified => "Portuguese".to_owned(), + Language::Other(tag) => tag.clone(), + } +} + +/// A concrete nudge against the one mistake DESIGN.md §15 calls out by name: +/// answering the wrong Portuguese. Generic advice ("respect the variant") +/// is easy for a model to nod at and ignore; naming the words it must not +/// reach for is not. +fn variant_note(target: &Language) -> Option<&'static str> { + match target { + Language::PortuguesePortugal => Some( + "This is continental European Portuguese, not Brazilian Portuguese: write \"tu\", \ + \"comboio\", \"casa de banho\" and European spelling and verb forms, never \ + Brazilian ones.", + ), + Language::PortugueseBrazil => Some( + "This is Brazilian Portuguese, not European Portuguese: write \"você\", \"trem\", \ + \"banheiro\" and Brazilian spelling and verb forms, never European ones.", + ), + _ => None, + } +} + +/// The instruction every LLM-shaped backend sends ahead of the batch. Shared +/// rather than per-backend so the reply contract — JSON only, every cue back +/// under its original number — cannot drift between them. +pub(crate) fn system_prompt(source: &Language, target: &Language) -> String { + let mut prompt = format!( + "You translate subtitle cues for a film or TV episode from {} into {}. Write natural, \ + spoken subtitle language a native speaker would actually use, not a stiff literal \ + translation.", + language_name(source), + language_name(target) + ); + if let Some(note) = variant_note(target) { + prompt.push(' '); + prompt.push_str(note); + } + prompt.push_str( + " Reply with nothing but a JSON array, one object per cue, each shaped \ + {\"number\": , \"text\": \"\"}, in the \ + order you received them. Every cue you were given must appear exactly once, under its \ + original number: never add, drop, merge or renumber a cue. No prose, no markdown code \ + fences, no explanation — JSON only.", + ); + prompt +} + /// Everything that can go wrong translating a subtitle. /// /// Kept apart from the provider [`Error`](crate::Error): the two report to