Skip to content

Commit 562adf2

Browse files
authored
Merge pull request #122 from iautolab/refactor/clean-retry-logic
refactor: clean up _translate_chunk retry logic and add retry streak …
2 parents 58ac58c + 1f9c03a commit 562adf2

2 files changed

Lines changed: 88 additions & 33 deletions

File tree

openlrc/translate.py

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class LLMTranslator(Translator):
3232
"""
3333

3434
CHUNK_SIZE = 30
35+
RETRY_STREAK = 10 # Number of consecutive chunks to use retry model after a failure
3536

3637
def __init__(
3738
self,
@@ -86,6 +87,11 @@ def make_chunks(texts: list[str], chunk_size: int = 30) -> list[list[tuple[int,
8687

8788
return chunks
8889

90+
@staticmethod
91+
def _is_valid_translation(translated: list[str] | None, expected_len: int) -> bool:
92+
"""Check whether a translation result is usable (non-empty and correct line count)."""
93+
return translated is not None and len(translated) == expected_len
94+
8995
def _translate_chunk(
9096
self,
9197
translator_agent: ChunkedTranslatorAgent,
@@ -97,56 +103,65 @@ def _translate_chunk(
97103
"""
98104
Translate a single chunk of text, with retry mechanism.
99105
100-
This method attempts to translate the chunk using the primary translator agent.
101-
If the translation fails or is inconsistent, it may use a retry agent or remove the glossary.
106+
Tries the primary agent first, then optionally falls back to a retry agent.
107+
Each agent attempt includes a glossary-removal retry when the line count
108+
is inconsistent.
102109
103-
Args:
104-
translator_agent (ChunkedTranslatorAgent): Primary agent for translation.
105-
chunk (List[Tuple[int, str]]): Chunk of text to translate.
106-
context (TranslationContext): Current translation context.
107-
chunk_id (int): ID of the current chunk.
108-
retry_agent (Optional[ChunkedTranslatorAgent]): Agent for retry attempts.
109-
110-
Returns:
111-
Tuple[List[str], TranslationContext]: Translated texts and updated context.
110+
Returns the best available result. The caller should check whether
111+
``len(translated) == len(chunk)`` to decide if further fallback
112+
(e.g. split or atomic translation) is needed.
112113
"""
114+
expected = len(chunk)
113115

114-
def handle_translation(agent: ChunkedTranslatorAgent) -> tuple[list[str] | None, TranslationContext | None]:
116+
def _try_agent(agent: ChunkedTranslatorAgent) -> tuple[list[str] | None, TranslationContext | None]:
117+
"""Single agent attempt: translate, then retry without glossary if line count mismatches."""
115118
trans: list[str] | None = None
116-
updated_context: TranslationContext | None = None
119+
ctx: TranslationContext | None = None
117120
try:
118-
trans, updated_context = agent.translate_chunk(chunk_id, chunk, context)
121+
trans, ctx = agent.translate_chunk(chunk_id, chunk, context)
119122
except ChatBotException:
120123
logger.error(f"Failed to translate chunk {chunk_id}.")
124+
return None, None
121125

122-
if trans is not None and len(trans) != len(chunk) and agent.info.glossary:
126+
if self._is_valid_translation(trans, expected):
127+
return trans, ctx
128+
129+
# Line count mismatch — retry without glossary if applicable.
130+
if trans is not None and agent.info.glossary:
123131
logger.warning(
124132
f"Agent {agent}: Removing glossary for chunk {chunk_id} due to inconsistent translation."
125133
)
126134
try:
127-
trans, updated_context = agent.translate_chunk(chunk_id, chunk, context, use_glossary=False)
135+
trans, ctx = agent.translate_chunk(chunk_id, chunk, context, use_glossary=False)
128136
except ChatBotException:
129137
logger.error(f"Failed to translate chunk {chunk_id}.")
130138

131-
return trans, updated_context
139+
return trans, ctx
132140

133-
translated: list[str] | None
134-
updated_ctx: TranslationContext | None
141+
translated: list[str] | None = None
142+
updated_ctx: TranslationContext | None = None
135143

144+
# Step 1: Try primary or retry agent based on retry streak.
136145
if self.use_retry_cnt == 0 or not retry_agent:
137-
translated, updated_ctx = handle_translation(translator_agent)
138-
139-
if retry_agent and (translated is None or len(translated) != len(chunk)):
140-
self.use_retry_cnt = 10 # Use retry_agent for the next 10 chunks
141-
logger.warning(
142-
f"Using retry agent {retry_agent} for chunk {chunk_id}, and next {self.use_retry_cnt} chunks."
143-
)
144-
translated, updated_ctx = handle_translation(retry_agent)
146+
translated, updated_ctx = _try_agent(translator_agent)
145147
else:
146148
logger.info(f"Using retry agent for chunk {chunk_id}, remaining retries: {self.use_retry_cnt}")
147-
translated, updated_ctx = handle_translation(retry_agent)
149+
translated, updated_ctx = _try_agent(retry_agent)
148150
self.use_retry_cnt -= 1
149151

152+
# Step 2: If primary failed and retry agent is available, switch to it.
153+
if not self._is_valid_translation(translated, expected) and retry_agent and self.use_retry_cnt == 0:
154+
self.use_retry_cnt = self.RETRY_STREAK
155+
logger.warning(
156+
f"Using retry agent {retry_agent} for chunk {chunk_id}, and next {self.use_retry_cnt} chunks."
157+
)
158+
translated, updated_ctx = _try_agent(retry_agent)
159+
160+
# Retry agent also failed — reset streak so next chunk tries primary first.
161+
if not self._is_valid_translation(translated, expected):
162+
logger.warning(f"Retry agent also failed for chunk {chunk_id}, resetting retry streak.")
163+
self.use_retry_cnt = 0
164+
150165
if not translated:
151166
raise ChatBotException(f"Failed to translate chunk {chunk_id}.")
152167

@@ -220,12 +235,8 @@ def translate(
220235
atomic = False
221236
translated, context = self._translate_chunk(translator_agent, chunk, context, i, retry_agent=retry_agent)
222237
chunk_texts = [c[1] for c in chunk]
223-
# Proofreader Not fully tested
224-
# localized_trans = proofreader.proofread(
225-
# texts=chunk_texts, translations=translated, context=context
226-
# )
227238

228-
if len(translated) != len(chunk):
239+
if not self._is_valid_translation(translated, len(chunk)):
229240
logger.warning(
230241
f"Chunk {i} translation length inconsistent: {len(translated)} vs {len(chunk)},"
231242
f" Attempting atomic translation."

tests/test_llm_translator.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,50 @@ def test_retry_agent_used_on_primary_failure(self, mock_agent_cls, mock_reviewer
250250
primary_agent.translate_chunk.assert_called_once()
251251
retry_agent.translate_chunk.assert_called_once()
252252

253+
@patch("openlrc.translate.ContextReviewerAgent")
254+
@patch("openlrc.translate.ChunkedTranslatorAgent")
255+
def test_retry_streak_resets_when_retry_agent_also_fails(self, mock_agent_cls, mock_reviewer_cls):
256+
"""When retry agent also returns wrong length, use_retry_cnt resets to 0."""
257+
texts = ["Hello", "World"]
258+
259+
primary_agent = MagicMock()
260+
primary_agent.cost = 0
261+
primary_agent.info.glossary = None
262+
primary_agent.translate_chunk.return_value = (
263+
["only_one"],
264+
TranslationContext(summary="s", scene="sc", guideline="g"),
265+
)
266+
267+
retry_agent = MagicMock()
268+
retry_agent.cost = 0
269+
retry_agent.info.glossary = None
270+
# Retry also returns wrong length
271+
retry_agent.translate_chunk.return_value = (
272+
["still_wrong"],
273+
TranslationContext(summary="s", scene="sc", guideline="g"),
274+
)
275+
276+
mock_agent_cls.side_effect = [primary_agent, retry_agent]
277+
278+
mock_reviewer = mock_reviewer_cls.return_value
279+
mock_reviewer.build_context.return_value = "guideline"
280+
281+
translator = self._make_translator(chunk_size=30, retry_chatbot=_make_mock_chatbot())
282+
283+
# Mock atomic_translate to provide fallback
284+
translator.atomic_translate = MagicMock(return_value=["你好", "世界"])
285+
286+
with tempfile.TemporaryDirectory() as tmpdir:
287+
compare_path = Path(tmpdir) / "compare.json"
288+
result = translator.translate(texts, "en", "zh", compare_path=compare_path)
289+
290+
self.assertEqual(result, ["你好", "世界"])
291+
# Both agents were tried
292+
primary_agent.translate_chunk.assert_called_once()
293+
retry_agent.translate_chunk.assert_called_once()
294+
# Streak should be reset to 0 after retry agent also failed
295+
self.assertEqual(translator.use_retry_cnt, 0)
296+
253297
@patch("openlrc.translate.ContextReviewerAgent")
254298
@patch("openlrc.translate.ChunkedTranslatorAgent")
255299
def test_resume_from_compare_file(self, mock_agent_cls, mock_reviewer_cls):

0 commit comments

Comments
 (0)