Preserve semantic note layouts and checkpoint on provider quota failures
This commit is contained in:
@@ -61,9 +61,13 @@ class BackfillEngine:
|
||||
new_tag['class'] = classes + ['translation']
|
||||
heading = entry.context and (entry.context.startswith('navigation-heading:') or entry.context in {'chapter','section','subsection'})
|
||||
special = re.search(r'caption|footnote|endnote|epigraph|verse|poem|center|attribution|dedication', ' '.join(element.get('class', [])), re.I)
|
||||
semantic_note = any(
|
||||
re.search(r'\b(?:doc-)?(?:footnote|endnote|endnotes|bibliography|index|credits)\b',
|
||||
str(node.get('role', '')) + ' ' + str(node.get('epub:type', '')))
|
||||
for node in [element, *element.parents] if getattr(node, 'attrs', None))
|
||||
prose = ((tag_name in {'p','div'} or (tag_name=='span' and element.get('data-translation-unit')))
|
||||
and not heading and entry.context != 'epigraph'
|
||||
and not special and not re.search(r'text-align\s*:\s*(?:center|right)', element.get('style',''),re.I)
|
||||
and not semantic_note and not special and not re.search(r'text-align\s*:\s*(?:center|right)', element.get('style',''),re.I)
|
||||
and not element.find_parent(['blockquote', 'figure', 'aside'])
|
||||
and not element.find_parent(attrs={'epub:type': re.compile(r'footnote|endnote')}))
|
||||
# Chinese body paragraphs use two em even when the publisher's
|
||||
|
||||
@@ -11,6 +11,10 @@ class ExtractionError(EpubTranslatorError):
|
||||
class TranslationError(EpubTranslatorError):
|
||||
pass
|
||||
|
||||
class ProviderAccessError(TranslationError):
|
||||
"""Credentials or quota require intervention rather than another retry."""
|
||||
pass
|
||||
|
||||
class RestorationError(EpubTranslatorError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ def sanitize_automatic_terms(terms, ambiguous_aliases):
|
||||
keep = []
|
||||
for alias in term.get('aliases', []):
|
||||
unsafe = alias.casefold() in COMMON_ALIASES or re.fullmatch(r'[a-z]+', alias)
|
||||
if re.fullmatch(r'[A-Za-z][A-Za-z.-]*', alias) and alias != term['source']:
|
||||
alias_core = re.sub(r'^(?:the|a|an)\s+', '', alias, flags=re.IGNORECASE)
|
||||
if re.fullmatch(r'[A-Za-z][A-Za-z.-]*', alias_core) and alias != term['source']:
|
||||
acronym = bool(re.fullmatch(r'[A-Z][A-Z.-]+', alias))
|
||||
nickname = 'nickname' in term.get('notes', '').casefold() and alias in term.get('notes', '')
|
||||
if not acronym and not nickname:
|
||||
|
||||
@@ -5,12 +5,13 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from openai import AsyncOpenAI
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
from openai import AsyncOpenAI, APIStatusError
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_not_exception_type
|
||||
from loguru import logger
|
||||
|
||||
from src.common.data_model import ManifestEntry
|
||||
from src.common.utils import setup_logger, ensure_directory
|
||||
from src.common.exceptions import ProviderAccessError
|
||||
|
||||
logger = setup_logger("llm_client")
|
||||
|
||||
@@ -238,10 +239,15 @@ Output Format:
|
||||
"""Generic chat completion."""
|
||||
return await self._make_request(system_prompt, user_prompt)
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=retry_if_not_exception_type(ProviderAccessError))
|
||||
async def _make_request(self, system_prompt: str, user_prompt: str) -> str:
|
||||
if getattr(self, '_access_error', None):
|
||||
raise self._access_error
|
||||
await self.rate_limiter.acquire()
|
||||
try:
|
||||
if getattr(self, '_access_error', None):
|
||||
raise self._access_error
|
||||
options = {"max_completion_tokens": self.max_output_tokens}
|
||||
if self.temperature is not None:
|
||||
options["temperature"] = self.temperature
|
||||
@@ -257,6 +263,13 @@ Output Format:
|
||||
if choice.finish_reason != "stop" or not choice.message.content:
|
||||
raise ValueError(f"Incomplete model response: {choice.finish_reason}")
|
||||
return choice.message.content.strip()
|
||||
except APIStatusError as exc:
|
||||
if exc.status_code in {401, 402, 403}:
|
||||
reason = 'quota exhausted' if exc.status_code == 402 else 'access denied'
|
||||
self._access_error = ProviderAccessError(
|
||||
f'API HTTP {exc.status_code}: {reason}; restore access and resume with the saved manifest.')
|
||||
raise self._access_error from None
|
||||
raise
|
||||
finally:
|
||||
self.rate_limiter.release()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections import defaultdict
|
||||
from src.assembly.format_restorer import FormatRestorer
|
||||
from src.translation.glossary import BookGlossary
|
||||
from src.common.utils import setup_logger
|
||||
from src.common.exceptions import ProviderAccessError
|
||||
|
||||
logger = setup_logger('translator')
|
||||
DEFAULT_CHUNK_SIZE = 24000
|
||||
@@ -52,6 +53,8 @@ class Translator:
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
pending, glossary=self.glossary.relevant(pending), instruction=instruction, mode='bilingual')
|
||||
except ProviderAccessError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning('Translation request failed (%s)', type(exc).__name__)
|
||||
results = {}
|
||||
@@ -87,7 +90,14 @@ class Translator:
|
||||
async def run(chunk):
|
||||
async with semaphore:
|
||||
await self._translate_chunk(chunk, profile)
|
||||
await asyncio.gather(*(run(c) for c in chunks))
|
||||
tasks = [asyncio.create_task(run(c)) for c in chunks]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except ProviderAccessError:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
raise
|
||||
return entries
|
||||
|
||||
async def translate_chapter(self, entries, file_path, profile):
|
||||
|
||||
Reference in New Issue
Block a user