diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5a9fb..6ec90ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## 2026-10-08 — uniform Chinese Part ordinals and full-book mode +- Preserve semantic credits, index, bibliography and note layouts instead of forcing body indentation. +- Stop on provider credential/quota failures (HTTP 401/402/403), cancel queued requests and checkpoint completed translations for resumption. - Reset publisher digit-only enlargement and color on translated Part numbers so “第四部” displays uniformly. - Add `--complete-book` to include notes, bibliography, index, copyright and non-spine text documents. - Add explicit configuration/credential file options and a Zenmux Sonnet 5.5 configuration without credentials. diff --git a/doc/QA_2026-10-08.md b/doc/QA_2026-10-08.md index 4a10b91..e4b3a60 100644 --- a/doc/QA_2026-10-08.md +++ b/doc/QA_2026-10-08.md @@ -77,3 +77,21 @@ OCR、扫描页图文提取、版面中间结构与可点击目录的后续大 自动表进一步收紧:Father/Dad 不作为 Otosan 广告角色的全局别名;carry 不作为 carried interest 的全局别名;WHO 等同时是普通词的短标记不强制套用机构名,即使正文为装饰性全大写。没有被模型显式列为别名的 Sun Yafang 也能揭示 Sun Microsystems 的 Sun 歧义。普通小写单词的不同词义不强制使用一种译法;用户手工表不套用自动筛选规则。累计 55 项测试通过,过滤记录及被重新翻译的单元保存在本地 QA 目录。 自动短别名仅保留有明确昵称说明的称呼和缩写,完整名称继续校验;月份、国籍词和缺乏消歧依据的单个姓名不作为全书硬约束。Masa 的显式昵称说明仍保留。术语正则表达式缓存避免大量术语反复编译。累计 56 项测试通过。 + + +## 第五轮:Sonnet 5.5 完整译本与额度中断 + +本轮真实 API 为 Zenmux `anthropic/claude-sonnet-5.5`。密钥从本机环境读取,未写入仓库。 + +- House of Huawei:2,837/2,837 个单元完成,生成纯中文和中英对照 EPUB;完整模式包含源 EPUB 中可提取的全部文字、目录、图片说明、版权及索引。 +- 两版均通过独立格式还原和术语校验,新增 EPUB 结构问题 0。Calibre 实际解析的 49 个目录条目与原书目标及片段相同,原有效目标全部有效。 +- 45 个图片/字体资源与原书逐字节一致。Calibre PDF 对照抽查第一部、第一章的标题/副标题/正文、选定照片与图注、图片来源列表以及作者说明。检查范围为抽样,没有宣称逐页审校或全书语义零错误。 +- 视觉检查发现 `role=doc-credits` 区域的中文来源列表被强加正文缩进,现依据 EPUB 语义排除 credits、index、bibliography、endnote/footnote 等区域,并验证保留原有内联缩进。重新生成译本、重新渲染来源列表后确认与英文列表对齐。 +- 原书作者说明明确尾注位于 `www.houseofhuawei.com/endnotes`;书内注号指向作者说明页。译本保留原链接与说明,没有把不存在于源 EPUB 的网站尾注当作已翻译内容。 +- 原书人物表的 Zhou Daiqi 中文写作“周代期”,按华为官方 [2011 年报](https://www.huawei.com/-/media/corp2020/annual-report/pdf/2011-cn-hw_126989.pdf) 校正为“周代琪”,译文中共 17 处,原文对照保留源书内容。另校对作者说明的一处标点与写作休假用语,记录保存在本地 QA 文件。 +- 原生阅读器检查在此轮因 Mac 锁屏而未能执行;此前第四部部号修复已在 Calibre 原生界面实际确认。本轮以 Calibre 渲染证据和真实 TOC 解析验收所抽查的排版及链接,不将 PDF 渲染替代为原生点击的证据。 +- Gambling Man:Zenmux 返回 HTTP 402 `quote_exceeded`(订阅额度耗尽),暂停请求。当前保留 1,350/3,942 个通过最新规则的真实译文;一处受新增歧义筛选影响的缓存重新待译。不生成或标记全书完成译本。额度恢复后沿原清单继续,无需重译已验收的缓存。 + +新增提供商额度/权限错误的明确异常:HTTP 401/402/403 不进行自动重试,并取消排队任务、保存已完成译文。瞬时网络错误仍按原策略重试。累计 60 项测试通过。自动别名筛选也覆盖带冠词的普通短称,例如国内期刊的“the journal”不能被全局强制译成《华尔街日报》。 + +本地证据:`.work/House of Huawei/full-book-qa.json`、`.work/full-sonnet55/qa-house-opening/`、`qa-house-photos-notes/`、`qa-house-author-note/`、`Gambling Man-status.json`。译本存于 `output/sonnet55/`,未推送书籍文件。 diff --git a/src/assembly/backfiller.py b/src/assembly/backfiller.py index 41fb117..909c5d4 100644 --- a/src/assembly/backfiller.py +++ b/src/assembly/backfiller.py @@ -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 diff --git a/src/common/exceptions.py b/src/common/exceptions.py index 2ca7b05..77b8f75 100644 --- a/src/common/exceptions.py +++ b/src/common/exceptions.py @@ -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 diff --git a/src/translation/glossary.py b/src/translation/glossary.py index 20e37d3..7be1686 100644 --- a/src/translation/glossary.py +++ b/src/translation/glossary.py @@ -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: diff --git a/src/translation/llm_client.py b/src/translation/llm_client.py index 96b8a88..721720b 100644 --- a/src/translation/llm_client.py +++ b/src/translation/llm_client.py @@ -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() diff --git a/src/translation/translator_engine.py b/src/translation/translator_engine.py index 0e0c361..f889279 100644 --- a/src/translation/translator_engine.py +++ b/src/translation/translator_engine.py @@ -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): diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 87db863..8d33f06 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -20,6 +20,8 @@ def fixture(tmp_path): chapter.content = '''

1 ROOTS

Opening prose.

A caption.

Quoted text.

+

1: Photo credit.

+

A semantic endnote.

Second paragraph.1

''' part = epub.EpubHtml(uid='part', file_name='Text/part.xhtml', title='Part 1') part.content = '

PART 1
BOY GENIUS

' @@ -84,9 +86,11 @@ def test_headings_nav_ncx_contents_share_translation_and_keep_links(tmp_path,mod p=ch.find(attrs={'data-translation-for':key}) if mode=='bilingual' else ch.find(id=key) assert p['data-translation-role']=='body' assert 'text-indent' not in p.get('style','') - for key in ['caption','quote']: + for key in ['caption','quote','credit','semantic-note']: p=ch.find(attrs={'data-translation-for':key}) if mode=='bilingual' else ch.find(id=key) assert p['data-translation-role']=='other' + if key in {'credit','semantic-note'}: + assert 'text-indent:0 !important' in p['style'] notes=BeautifulSoup(after.read(prefix+'Text/notes.xhtml'),'html.parser') assert notes.find(id='note') and notes.find('a',href='ch.xhtml#second') diff --git a/tests/test_regressions.py b/tests/test_regressions.py index f1d667e..2eb0272 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -119,11 +119,13 @@ def test_automatic_glossary_does_not_force_generic_senses_or_implicit_surnames() def test_auto_aliases_require_evidence_but_explicit_nicknames_remain(): from src.translation.glossary import sanitize_automatic_terms terms, _ = sanitize_automatic_terms([ + {'source': 'The Wall Street Journal', 'target': '《华尔街日报》', 'aliases': ['the Journal']}, {'source': 'Theresa May', 'target': '特蕾莎·梅', 'aliases': ['May']}, {'source': 'University of California, Berkeley', 'target': '加州大学伯克利分校', 'aliases': ['Berkeley', 'UC Berkeley']}, {'source': 'Masayoshi Son', 'target': '孙正义', 'aliases': ['Masa'], 'notes': 'Masa is his nickname.'}], set()) glossary = BookGlossary(terms) assert not glossary.validate('In May he stayed at the Berkeley hotel.', '五月他住在伯克利酒店。') + assert not glossary.validate('A domestic journal reported. The journal said.', '一家国内期刊报道。该期刊说。') assert glossary.validate('Masa studied at UC Berkeley.', '他去上学。') assert glossary.validate('Theresa May visited.', '五月来访。') @@ -333,3 +335,49 @@ def test_output_budget_and_truncation_handling(finish_reason): else: with pytest.raises(ValueError,match='Incomplete'): asyncio.run(call) + + +@pytest.mark.parametrize('status', [401, 402, 403]) +def test_access_errors_stop_retries_and_queued_requests(status): + import httpx + from openai import APIStatusError + from types import SimpleNamespace + from src.common.exceptions import ProviderAccessError + from src.translation.llm_client import RateLimiter + client = object.__new__(LLMClient) + client.model = 'test'; client.max_output_tokens = 100; client.temperature = None + client.rate_limiter = RateLimiter(0, 1) + calls = [] + async def create(**kwargs): + calls.append(kwargs) + response = httpx.Response(status, request=httpx.Request('POST', 'https://example.test')) + raise APIStatusError('provider response', response=response, body={}) + client.client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + async def run(): + for _ in range(2): + with pytest.raises(ProviderAccessError, match=f'HTTP {status}'): + await client._make_request('system', 'source') + asyncio.run(run()) + assert len(calls) == 1 + + +def test_quota_failure_preserves_completed_translation_and_checkpoint(): + from src.common.exceptions import ProviderAccessError + class Client: + model = 'test' + calls = 0 + async def translate_chunk(self, items, **kwargs): + self.calls += 1 + if self.calls > 1: + raise ProviderAccessError('API HTTP 402: quota exhausted') + return {items[0].entry_id: '已完成。'} + entries = [entry(f'

Plain source {i}.

') for i in range(3)] + for i, item in enumerate(entries): item.entry_id = str(i) + snapshots = [] + translator = Translator(Client(), chunk_size=1, max_concurrent=1, + on_chunk_complete=lambda: snapshots.append([e.translated_text for e in entries])) + with pytest.raises(ProviderAccessError): + asyncio.run(translator.translate(entries, BookProfile(title='Test', author='Test'))) + assert entries[0].translated_text == '已完成。' + assert not any(e.translated_text for e in entries[1:]) + assert snapshots and snapshots[-1][0] == '已完成。'