use crate::core::config::TranslationConfig;
use crate::types::{DocumentStructure, Element, ExtractedDocument, LlmUsage, Metadata, PageContent, Table};
use super::llm::translate_segment;
const MAX_BATCH_ITEMS: usize = 25;
const DEFAULT_TRANSLATION_BATCH_TEMPLATE: &str = "\
You are a precise translation engine. You will receive a JSON array of independent text segments.
Translate each segment {% if source_lang and source_lang != 'auto' %}from {{ source_lang }} {% endif %}\
into {{ target_lang }}.
Rules:
- Preserve the original meaning of each segment exactly.
- Translate every array element independently; never merge, reorder, or drop elements.
- Return a JSON array with exactly the same number of elements, in the same order.
- Do not add commentary or explanations — respond with the JSON array only.
{% if preserve_markup %}- Preserve Markdown formatting (headings, lists, emphasis, links, code blocks) and HTML tags exactly as they appear.\
{% else %}- Return plain text only for each segment.{% endif %}
- If a segment is already in {{ target_lang }}, return it unchanged.
- If a segment is empty, return an empty string for it.
Segments (JSON array):
{{ segments }}";
fn render_batch_prompt(config: &TranslationConfig, texts: &[String], preserve_markup: bool) -> crate::Result<String> {
let segments = serde_json::to_string(texts)
.map_err(|e| crate::XbergError::parsing(format!("failed to encode translation batch as JSON: {e}")))?;
let ctx = minijinja::context! {
target_lang => &config.target_lang,
source_lang => config.source_lang.as_deref().unwrap_or("auto"),
preserve_markup => preserve_markup,
segments => segments,
};
crate::llm::prompts::render_template(DEFAULT_TRANSLATION_BATCH_TEMPLATE, &ctx)
}
async fn translate_batch_call(
config: &TranslationConfig,
texts: &[String],
preserve_markup: bool,
source_label: &str,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<Vec<String>> {
let prompt = render_batch_prompt(config, texts, preserve_markup)?;
let schema = serde_json::json!({
"type": "array",
"items": { "type": "string" },
"minItems": texts.len(),
"maxItems": texts.len(),
});
let (value, usage) = crate::llm::structured::complete_with_json_schema(
&config.llm,
&prompt,
"translated_segments",
&schema,
source_label,
)
.await?;
if let Some(u) = usage {
usages.push(u);
}
let translated: Vec<String> = serde_json::from_value(value).map_err(|e| {
crate::XbergError::parsing(format!(
"LLM translation batch ({source_label}) did not return a JSON array of strings: {e}"
))
})?;
if translated.len() != texts.len() {
return Err(crate::XbergError::parsing(format!(
"LLM translation batch ({source_label}) returned {} segment(s), expected {}",
translated.len(),
texts.len()
)));
}
Ok(translated)
}
async fn translate_batch(
config: &TranslationConfig,
texts: &[String],
preserve_markup: bool,
source_label: &str,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<Vec<String>> {
let mut output = texts.to_vec();
let non_empty_indices: Vec<usize> = texts
.iter()
.enumerate()
.filter(|(_, text)| !text.trim().is_empty())
.map(|(index, _)| index)
.collect();
for group in non_empty_indices.chunks(MAX_BATCH_ITEMS) {
let group_texts: Vec<String> = group.iter().map(|&index| texts[index].clone()).collect();
let translated = translate_batch_call(config, &group_texts, preserve_markup, source_label, usages).await?;
for (&index, translated_text) in group.iter().zip(translated) {
output[index] = translated_text;
}
}
Ok(output)
}
async fn translate_optional_fields(
config: &TranslationConfig,
mut fields: Vec<&mut Option<String>>,
source_label: &str,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
let present: Vec<usize> = fields
.iter()
.enumerate()
.filter(|(_, field)| field.is_some())
.map(|(index, _)| index)
.collect();
if present.is_empty() {
return Ok(());
}
let texts: Vec<String> = present
.iter()
.map(|&index| fields[index].clone().unwrap_or_default())
.collect();
let translated = translate_batch(config, &texts, false, source_label, usages).await?;
for (slot, translated_text) in present.into_iter().zip(translated) {
*fields[slot] = Some(translated_text);
}
Ok(())
}
async fn translate_string_list(
config: &TranslationConfig,
list: &mut Option<Vec<String>>,
source_label: &str,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
let Some(items) = list.as_mut() else {
return Ok(());
};
if items.is_empty() {
return Ok(());
}
let translated = translate_batch(config, items, false, source_label, usages).await?;
*items = translated;
Ok(())
}
async fn translate_table(
table: &mut Table,
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
let mut positions: Vec<(usize, usize)> = Vec::new();
let mut texts: Vec<String> = Vec::new();
for (row_index, row) in table.cells.iter().enumerate() {
for (col_index, cell) in row.iter().enumerate() {
positions.push((row_index, col_index));
texts.push(cell.clone());
}
}
if !texts.is_empty() {
let translated = translate_batch(config, &texts, false, "translation_table_cells", usages).await?;
for ((row_index, col_index), value) in positions.into_iter().zip(translated) {
table.cells[row_index][col_index] = value;
}
}
table.markdown = translate_segment(config, &table.markdown, true, "translation_table_markdown", usages).await?;
Ok(())
}
async fn translate_tables(
tables: &mut [Table],
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
for table in tables.iter_mut() {
translate_table(table, config, usages).await?;
}
Ok(())
}
async fn translate_pages(
pages: Option<&mut Vec<PageContent>>,
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
let Some(pages) = pages else {
return Ok(());
};
for page in pages.iter_mut() {
page.content = translate_segment(config, &page.content, false, "translation_page_content", usages).await?;
translate_optional_fields(
config,
vec![&mut page.speaker_notes, &mut page.section_name, &mut page.sheet_name],
"translation_page_labels",
usages,
)
.await?;
if let Some(hierarchy) = page.hierarchy.as_mut() {
let texts: Vec<String> = hierarchy.blocks.iter().map(|block| block.text.clone()).collect();
if !texts.is_empty() {
let translated = translate_batch(config, &texts, false, "translation_page_hierarchy", usages).await?;
for (block, value) in hierarchy.blocks.iter_mut().zip(translated) {
block.text = value;
}
}
}
for table in page.tables.iter_mut() {
let table = std::sync::Arc::make_mut(table);
translate_table(table, config, usages).await?;
}
}
Ok(())
}
async fn translate_elements(
elements: Option<&mut Vec<Element>>,
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
let Some(elements) = elements else {
return Ok(());
};
let texts: Vec<String> = elements.iter().map(|element| element.text.clone()).collect();
if texts.is_empty() {
return Ok(());
}
let translated = translate_batch(config, &texts, false, "translation_elements", usages).await?;
for (element, value) in elements.iter_mut().zip(translated) {
element.text = value;
}
Ok(())
}
async fn translate_metadata(
metadata: &mut Metadata,
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
translate_optional_fields(
config,
vec![
&mut metadata.title,
&mut metadata.subject,
&mut metadata.category,
&mut metadata.abstract_text,
],
"translation_metadata",
usages,
)
.await?;
translate_string_list(config, &mut metadata.keywords, "translation_metadata_keywords", usages).await?;
translate_string_list(config, &mut metadata.tags, "translation_metadata_tags", usages).await?;
Ok(())
}
async fn translate_document_structure(
document: Option<&mut DocumentStructure>,
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
let Some(document) = document else {
return Ok(());
};
let mut texts: Vec<String> = Vec::new();
for node in document.nodes.iter_mut() {
node.content.for_each_text_field_mut(|text| texts.push(text.clone()));
}
if texts.is_empty() {
return Ok(());
}
let translated = translate_batch(config, &texts, false, "translation_document_structure", usages).await?;
let mut translated = translated.into_iter();
for node in document.nodes.iter_mut() {
node.content.for_each_text_field_mut(|text| {
if let Some(value) = translated.next() {
*text = value;
}
});
}
Ok(())
}
pub(super) async fn translate_secondary_fields(
result: &mut ExtractedDocument,
config: &TranslationConfig,
usages: &mut Vec<LlmUsage>,
) -> crate::Result<()> {
translate_tables(&mut result.tables, config, usages).await?;
translate_pages(result.pages.as_mut(), config, usages).await?;
translate_elements(result.elements.as_mut(), config, usages).await?;
translate_metadata(&mut result.metadata, config, usages).await?;
translate_document_structure(result.document.as_mut(), config, usages).await?;
Ok(())
}