use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use async_trait::async_trait;
use ct2rs::tokenizers::sentencepiece::Tokenizer as SpmTokenizer;
use ct2rs::{BatchType, ComputeType, Config, TranslationOptions, Translator};
use tokio::sync::OnceCell;
use super::backend::TranslationBackend;
use super::lang::Lang;
use super::split::{join_sentences, split_sentences};
use crate::error::{FinanceError, Result};
type OpusTranslator = Translator<SpmTokenizer>;
const ARGOS_BASE_URL: &str = "https://argos-net.com/v1";
const CACHE_ENV: &str = "FINANCE_QUERY_TRANSLATION_CACHE";
const THREADS_ENV: &str = "FINANCE_QUERY_TRANSLATION_THREADS";
const PRELOAD_ENV: &str = "FINANCE_QUERY_TRANSLATION_PRELOAD";
const MARIAN_CONFIG_JSON: &str = r#"{
"add_source_bos": false,
"add_source_eos": false,
"bos_token": "<s>",
"decoder_start_token": "</s>",
"eos_token": "</s>",
"unk_token": "<unk>"
}
"#;
const PACKAGES: &[(&str, &str)] = &[
("ar", "translate-en_ar-1_0"),
("az", "translate-en_az-1_5"),
("bg", "translate-en_bg-1_9"),
("bn", "translate-en_bn-1_9"),
("ca", "translate-en_ca-1_9"),
("cs", "translate-en_cs-1_9_6"),
("da", "translate-en_da-1_9"),
("de", "translate-en_de-1_3"),
("el", "translate-en_el-1_9"),
("eo", "translate-en_eo-1_5"),
("es", "translate-en_es-1_0"),
("et", "translate-en_et-1_9"),
("eu", "translate-en_eu-1_9"),
("fa", "translate-en_fa-1_5"),
("fi", "translate-en_fi-1_9"),
("fr", "translate-en_fr-1_9"),
("ga", "translate-en_ga-1_1"),
("gl", "translate-en_gl-1_9"),
("he", "translate-en_he-1_5"),
("hi", "translate-en_hi-1_1"),
("hu", "translate-en_hu-1_9"),
("id", "translate-en_id-1_9"),
("it", "translate-en_it-1_0"),
("ja", "translate-en_ja-1_1"),
("ko", "translate-en_ko-1_1"),
("ky", "translate-en_ky-1_4"),
("lt", "translate-en_lt-1_9"),
("lv", "translate-en_lv-1_9"),
("ms", "translate-en_ms-1_9"),
("nb", "translate-en_nb-1_9"),
("nl", "translate-en_nl-1_8"),
("pl", "translate-en_pl-1_9"),
("pt", "translate-en_pt-1_9"),
("ro", "translate-en_ro-1_9"),
("ru", "translate-en_ru-1_9"),
("sk", "translate-en_sk-1_9"),
("sl", "translate-en_sl-1_9"),
("sq", "translate-en_sq-1_9"),
("sv", "translate-en_sv-1_5"),
("th", "translate-en_th-1_9"),
("tl", "translate-en_tl-1_9"),
("tr", "translate-en_tr-1_5"),
("uk", "translate-en_uk-1_4"),
("ur", "translate-en_ur-1_9"),
("vi", "translate-en_vi-1_9"),
("zh", "translate-en_zh-1_9"),
("zt", "translate-en_zt-1_9"),
];
fn package_for(lang: &Lang) -> Option<&'static str> {
let key = match (lang.primary(), lang.code().as_str()) {
("zh", "zh-Hant") => "zt",
("zh", _) => "zh",
(primary, _) => primary,
};
PACKAGES
.iter()
.find_map(|(code, stem)| (*code == key).then_some(*stem))
}
fn translation_error(context: impl std::fmt::Display) -> FinanceError {
FinanceError::TranslationError {
context: context.to_string(),
}
}
fn threads() -> usize {
std::env::var(THREADS_ENV)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get().min(8))
.unwrap_or(4)
})
.max(1)
}
fn cache_root() -> PathBuf {
if let Ok(dir) = std::env::var(CACHE_ENV) {
return PathBuf::from(dir);
}
if let Ok(hf) = std::env::var("HF_HOME") {
return PathBuf::from(hf).join("argos");
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".cache/huggingface/argos")
}
pub(crate) struct OpusMtBackend {
models: RwLock<HashMap<String, Arc<OnceCell<Arc<OpusTranslator>>>>>,
}
pub(crate) fn shared() -> Arc<OpusMtBackend> {
static SHARED: OnceLock<Arc<OpusMtBackend>> = OnceLock::new();
SHARED
.get_or_init(|| {
Arc::new(OpusMtBackend {
models: RwLock::new(HashMap::new()),
})
})
.clone()
}
pub async fn preload() -> Result<()> {
let Ok(list) = std::env::var(PRELOAD_ENV) else {
return Ok(());
};
let backend = shared();
for code in list.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let lang = Lang::parse(code)?;
backend.translator(&lang).await?;
}
Ok(())
}
fn extract_package(bytes: &[u8], dest: &Path) -> Result<(PathBuf, PathBuf)> {
let model_dir = dest.join("model");
let spm_path = dest.join("sentencepiece.model");
std::fs::create_dir_all(&model_dir).map_err(translation_error)?;
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(translation_error)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(translation_error)?;
let name = entry.name().replace('\\', "/");
let target = if name.ends_with("/sentencepiece.model") {
spm_path.clone()
} else if let Some(file) = name
.rsplit_once("/model/")
.map(|(_, f)| f)
.filter(|f| !f.is_empty() && !f.contains('/'))
{
model_dir.join(file)
} else {
continue;
};
let mut out = std::fs::File::create(&target).map_err(translation_error)?;
std::io::copy(&mut entry, &mut out).map_err(translation_error)?;
}
if !model_dir.join("model.bin").exists() || !spm_path.exists() {
return Err(translation_error(
"argos package missing model.bin or sentencepiece.model",
));
}
let shared_txt = model_dir.join("shared_vocabulary.txt");
if shared_txt.exists() && !model_dir.join("shared_vocabulary.json").exists() {
std::fs::copy(&shared_txt, model_dir.join("source_vocabulary.txt"))
.map_err(translation_error)?;
std::fs::copy(&shared_txt, model_dir.join("target_vocabulary.txt"))
.map_err(translation_error)?;
}
let config = model_dir.join("config.json");
if !config.exists() {
std::fs::write(&config, MARIAN_CONFIG_JSON).map_err(translation_error)?;
}
Ok((model_dir, spm_path))
}
fn load_translator(primary: &str, stem: &'static str) -> Result<OpusTranslator> {
let dest = cache_root().join(primary);
let model_dir = dest.join("model");
let spm_path = dest.join("sentencepiece.model");
if !(model_dir.join("model.bin").exists() && spm_path.exists()) {
let url = format!("{ARGOS_BASE_URL}/{stem}.argosmodel");
tracing::info!(%url, "downloading opus-mt model (one-time setup)");
let bytes = reqwest::blocking::get(&url)
.and_then(reqwest::blocking::Response::error_for_status)
.and_then(|r| r.bytes())
.map_err(|e| translation_error(format!("model download failed ({stem}): {e}")))?;
extract_package(&bytes, &dest)?;
}
let config = Config {
compute_type: ComputeType::INT8,
num_threads_per_replica: threads(),
..Config::default()
};
let tokenizer = SpmTokenizer::from_file(&spm_path, &spm_path)
.map_err(|e| translation_error(format!("tokenizer load failed: {e}")))?;
Translator::with_tokenizer(&model_dir, tokenizer, &config)
.map_err(|e| translation_error(format!("model load failed: {e}")))
}
impl OpusMtBackend {
async fn translator(&self, target: &Lang) -> Result<Arc<OpusTranslator>> {
let stem = package_for(target).ok_or_else(|| FinanceError::InvalidParameter {
param: "lang".into(),
reason: format!(
"language '{}' is not supported by the offline translation model",
target.code()
),
})?;
let primary = target.primary().to_string();
let cell = {
let mut models = self
.models
.write()
.map_err(|_| translation_error("translation model cache poisoned"))?;
models.entry(primary.clone()).or_default().clone()
};
cell.get_or_try_init(|| async {
let primary = primary.clone();
let translator = tokio::task::spawn_blocking(move || load_translator(&primary, stem))
.await
.map_err(|e| translation_error(format!("model load task failed: {e}")))??;
tracing::info!(lang = %target.code(), "opus-mt model loaded");
Ok(Arc::new(translator))
})
.await
.cloned()
}
}
#[async_trait]
impl TranslationBackend for OpusMtBackend {
fn id(&self) -> &'static str {
"opus-mt-offline"
}
fn supports(&self, target: &Lang) -> bool {
package_for(target).is_some()
}
async fn translate_batch(&self, texts: &[String], target: &Lang) -> Result<Vec<String>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let translator = self.translator(target).await?;
let mut segments: Vec<String> = Vec::new();
let mut spans: Vec<(usize, usize)> = Vec::with_capacity(texts.len());
for text in texts {
let start = segments.len();
segments.extend(split_sentences(text));
spans.push((start, segments.len()));
}
let results = tokio::task::spawn_blocking(move || {
let options = TranslationOptions {
beam_size: 1,
disable_unk: true,
max_batch_size: 2048,
batch_type: BatchType::Tokens,
..Default::default()
};
translator
.translate_batch(&segments, &options, None)
.map(|out| out.into_iter().map(|(text, _)| text).collect::<Vec<_>>())
})
.await
.map_err(|e| translation_error(format!("translation task failed: {e}")))?
.map_err(|e| translation_error(format!("translation failed: {e}")))?;
let without_space = target.joins_without_space();
Ok(spans
.into_iter()
.map(|(start, end)| join_sentences(&results[start..end], without_space))
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_lookup_resolves_major_languages() {
assert_eq!(
package_for(&Lang::parse("es").unwrap()),
Some("translate-en_es-1_0")
);
assert_eq!(
package_for(&Lang::parse("ja-JP").unwrap()),
Some("translate-en_ja-1_1")
);
assert_eq!(
package_for(&Lang::parse("zh-CN").unwrap()),
Some("translate-en_zh-1_9")
);
assert_eq!(
package_for(&Lang::parse("zh-TW").unwrap()),
Some("translate-en_zt-1_9")
);
assert_eq!(package_for(&Lang::parse("tlh").unwrap()), None);
}
#[tokio::test]
async fn unsupported_language_errors() {
let backend = shared();
let lang = Lang::parse("tlh").unwrap();
let texts = vec!["Hello".to_string()];
assert!(backend.translate_batch(&texts, &lang).await.is_err());
}
#[tokio::test]
#[ignore = "requires network access"]
async fn opusmt_backend_translates() {
let backend = shared();
let lang = Lang::parse("es").unwrap();
let texts = vec!["The company designs smartphones.".to_string()];
let out = backend.translate_batch(&texts, &lang).await.unwrap();
assert_eq!(out.len(), 1);
assert_ne!(out[0], texts[0]);
assert!(!out[0].contains("<unk>"));
}
}