refactor(arr): share the LLM prompt helpers across backends
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user