refactor(arr): share the LLM prompt helpers across backends

This commit is contained in:
Miguel Palhas
2026-08-25 01:44:52 +01:00
parent c6cfdff2c9
commit 4af6e0a083
2 changed files with 72 additions and 65 deletions
+6 -65
View File
@@ -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\": <the same number you were given>, \"text\": \"<translation>\"}, 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")
+66
View File
@@ -153,6 +153,72 @@ pub trait Backend: fmt::Debug + Send + Sync {
/// Result alias for translation.
pub type Result<T> = std::result::Result<T, Error>;
/// 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\": <the same number you were given>, \"text\": \"<translation>\"}, 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