Preserve semantic note layouts and checkpoint on provider quota failures

This commit is contained in:
Kai Tan
2026-10-08 09:35:22 +08:00
parent 4f124a0335
commit cab13568df
9 changed files with 111 additions and 7 deletions
+2
View File
@@ -1,5 +1,7 @@
## 2026-10-08 — uniform Chinese Part ordinals and full-book mode ## 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. - 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 `--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. - Add explicit configuration/credential file options and a Zenmux Sonnet 5.5 configuration without credentials.
+18
View File
@@ -77,3 +77,21 @@ OCR、扫描页图文提取、版面中间结构与可点击目录的后续大
自动表进一步收紧:Father/Dad 不作为 Otosan 广告角色的全局别名;carry 不作为 carried interest 的全局别名;WHO 等同时是普通词的短标记不强制套用机构名,即使正文为装饰性全大写。没有被模型显式列为别名的 Sun Yafang 也能揭示 Sun Microsystems 的 Sun 歧义。普通小写单词的不同词义不强制使用一种译法;用户手工表不套用自动筛选规则。累计 55 项测试通过,过滤记录及被重新翻译的单元保存在本地 QA 目录。 自动表进一步收紧:Father/Dad 不作为 Otosan 广告角色的全局别名;carry 不作为 carried interest 的全局别名;WHO 等同时是普通词的短标记不强制套用机构名,即使正文为装饰性全大写。没有被模型显式列为别名的 Sun Yafang 也能揭示 Sun Microsystems 的 Sun 歧义。普通小写单词的不同词义不强制使用一种译法;用户手工表不套用自动筛选规则。累计 55 项测试通过,过滤记录及被重新翻译的单元保存在本地 QA 目录。
自动短别名仅保留有明确昵称说明的称呼和缩写,完整名称继续校验;月份、国籍词和缺乏消歧依据的单个姓名不作为全书硬约束。Masa 的显式昵称说明仍保留。术语正则表达式缓存避免大量术语反复编译。累计 56 项测试通过。 自动短别名仅保留有明确昵称说明的称呼和缩写,完整名称继续校验;月份、国籍词和缺乏消歧依据的单个姓名不作为全书硬约束。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/`,未推送书籍文件。
+5 -1
View File
@@ -61,9 +61,13 @@ class BackfillEngine:
new_tag['class'] = classes + ['translation'] new_tag['class'] = classes + ['translation']
heading = entry.context and (entry.context.startswith('navigation-heading:') or entry.context in {'chapter','section','subsection'}) 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) 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'))) 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 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(['blockquote', 'figure', 'aside'])
and not element.find_parent(attrs={'epub:type': re.compile(r'footnote|endnote')})) and not element.find_parent(attrs={'epub:type': re.compile(r'footnote|endnote')}))
# Chinese body paragraphs use two em even when the publisher's # Chinese body paragraphs use two em even when the publisher's
+4
View File
@@ -11,6 +11,10 @@ class ExtractionError(EpubTranslatorError):
class TranslationError(EpubTranslatorError): class TranslationError(EpubTranslatorError):
pass pass
class ProviderAccessError(TranslationError):
"""Credentials or quota require intervention rather than another retry."""
pass
class RestorationError(EpubTranslatorError): class RestorationError(EpubTranslatorError):
pass pass
+2 -1
View File
@@ -29,7 +29,8 @@ def sanitize_automatic_terms(terms, ambiguous_aliases):
keep = [] keep = []
for alias in term.get('aliases', []): for alias in term.get('aliases', []):
unsafe = alias.casefold() in COMMON_ALIASES or re.fullmatch(r'[a-z]+', alias) 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)) acronym = bool(re.fullmatch(r'[A-Z][A-Z.-]+', alias))
nickname = 'nickname' in term.get('notes', '').casefold() and alias in term.get('notes', '') nickname = 'nickname' in term.get('notes', '').casefold() and alias in term.get('notes', '')
if not acronym and not nickname: if not acronym and not nickname:
+16 -3
View File
@@ -5,12 +5,13 @@ import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
from openai import AsyncOpenAI from openai import AsyncOpenAI, APIStatusError
from tenacity import retry, stop_after_attempt, wait_exponential from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_not_exception_type
from loguru import logger from loguru import logger
from src.common.data_model import ManifestEntry from src.common.data_model import ManifestEntry
from src.common.utils import setup_logger, ensure_directory from src.common.utils import setup_logger, ensure_directory
from src.common.exceptions import ProviderAccessError
logger = setup_logger("llm_client") logger = setup_logger("llm_client")
@@ -238,10 +239,15 @@ Output Format:
"""Generic chat completion.""" """Generic chat completion."""
return await self._make_request(system_prompt, user_prompt) 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: 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() await self.rate_limiter.acquire()
try: try:
if getattr(self, '_access_error', None):
raise self._access_error
options = {"max_completion_tokens": self.max_output_tokens} options = {"max_completion_tokens": self.max_output_tokens}
if self.temperature is not None: if self.temperature is not None:
options["temperature"] = self.temperature options["temperature"] = self.temperature
@@ -257,6 +263,13 @@ Output Format:
if choice.finish_reason != "stop" or not choice.message.content: if choice.finish_reason != "stop" or not choice.message.content:
raise ValueError(f"Incomplete model response: {choice.finish_reason}") raise ValueError(f"Incomplete model response: {choice.finish_reason}")
return choice.message.content.strip() 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: finally:
self.rate_limiter.release() self.rate_limiter.release()
+11 -1
View File
@@ -6,6 +6,7 @@ from collections import defaultdict
from src.assembly.format_restorer import FormatRestorer from src.assembly.format_restorer import FormatRestorer
from src.translation.glossary import BookGlossary from src.translation.glossary import BookGlossary
from src.common.utils import setup_logger from src.common.utils import setup_logger
from src.common.exceptions import ProviderAccessError
logger = setup_logger('translator') logger = setup_logger('translator')
DEFAULT_CHUNK_SIZE = 24000 DEFAULT_CHUNK_SIZE = 24000
@@ -52,6 +53,8 @@ class Translator:
try: try:
results = await self.llm_client.translate_chunk( results = await self.llm_client.translate_chunk(
pending, glossary=self.glossary.relevant(pending), instruction=instruction, mode='bilingual') pending, glossary=self.glossary.relevant(pending), instruction=instruction, mode='bilingual')
except ProviderAccessError:
raise
except Exception as exc: except Exception as exc:
logger.warning('Translation request failed (%s)', type(exc).__name__) logger.warning('Translation request failed (%s)', type(exc).__name__)
results = {} results = {}
@@ -87,7 +90,14 @@ class Translator:
async def run(chunk): async def run(chunk):
async with semaphore: async with semaphore:
await self._translate_chunk(chunk, profile) 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 return entries
async def translate_chapter(self, entries, file_path, profile): async def translate_chapter(self, entries, file_path, profile):
+5 -1
View File
@@ -20,6 +20,8 @@ def fixture(tmp_path):
chapter.content = '''<html><head></head><body><h2 id="ch1"><span class="line">1</span> chapter.content = '''<html><head></head><body><h2 id="ch1"><span class="line">1</span>
<span class="subtitle">ROOTS</span></h2><p id="opening" class="noindent" style="text-indent:0 !important; color:red">Opening prose.</p> <span class="subtitle">ROOTS</span></h2><p id="opening" class="noindent" style="text-indent:0 !important; color:red">Opening prose.</p>
<p id="caption" class="caption">A caption.</p><blockquote><p id="quote">Quoted text.</p></blockquote> <p id="caption" class="caption">A caption.</p><blockquote><p id="quote">Quoted text.</p></blockquote>
<div role="doc-credits"><p id="credit" style="text-indent:0 !important">1: Photo credit.</p></div>
<p role="doc-endnote" id="semantic-note" style="text-indent:0 !important">A semantic endnote.</p>
<p id="second">Second paragraph.<a epub:type="noteref" href="notes.xhtml#note">1</a></p></body></html>''' <p id="second">Second paragraph.<a epub:type="noteref" href="notes.xhtml#note">1</a></p></body></html>'''
part = epub.EpubHtml(uid='part', file_name='Text/part.xhtml', title='Part 1') part = epub.EpubHtml(uid='part', file_name='Text/part.xhtml', title='Part 1')
part.content = '<html><body><h1 id="part"><span>PART 1</span><br/><em>BOY GENIUS</em></h1></body></html>' part.content = '<html><body><h1 id="part"><span>PART 1</span><br/><em>BOY GENIUS</em></h1></body></html>'
@@ -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) p=ch.find(attrs={'data-translation-for':key}) if mode=='bilingual' else ch.find(id=key)
assert p['data-translation-role']=='body' assert p['data-translation-role']=='body'
assert 'text-indent' not in p.get('style','') 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) p=ch.find(attrs={'data-translation-for':key}) if mode=='bilingual' else ch.find(id=key)
assert p['data-translation-role']=='other' 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') notes=BeautifulSoup(after.read(prefix+'Text/notes.xhtml'),'html.parser')
assert notes.find(id='note') and notes.find('a',href='ch.xhtml#second') assert notes.find(id='note') and notes.find('a',href='ch.xhtml#second')
+48
View File
@@ -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(): def test_auto_aliases_require_evidence_but_explicit_nicknames_remain():
from src.translation.glossary import sanitize_automatic_terms from src.translation.glossary import sanitize_automatic_terms
terms, _ = sanitize_automatic_terms([ terms, _ = sanitize_automatic_terms([
{'source': 'The Wall Street Journal', 'target': '《华尔街日报》', 'aliases': ['the Journal']},
{'source': 'Theresa May', 'target': '特蕾莎·梅', 'aliases': ['May']}, {'source': 'Theresa May', 'target': '特蕾莎·梅', 'aliases': ['May']},
{'source': 'University of California, Berkeley', 'target': '加州大学伯克利分校', 'aliases': ['Berkeley', 'UC Berkeley']}, {'source': 'University of California, Berkeley', 'target': '加州大学伯克利分校', 'aliases': ['Berkeley', 'UC Berkeley']},
{'source': 'Masayoshi Son', 'target': '孙正义', 'aliases': ['Masa'], 'notes': 'Masa is his nickname.'}], set()) {'source': 'Masayoshi Son', 'target': '孙正义', 'aliases': ['Masa'], 'notes': 'Masa is his nickname.'}], set())
glossary = BookGlossary(terms) glossary = BookGlossary(terms)
assert not glossary.validate('In May he stayed at the Berkeley hotel.', '五月他住在伯克利酒店。') 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('Masa studied at UC Berkeley.', '他去上学。')
assert glossary.validate('Theresa May visited.', '五月来访。') assert glossary.validate('Theresa May visited.', '五月来访。')
@@ -333,3 +335,49 @@ def test_output_budget_and_truncation_handling(finish_reason):
else: else:
with pytest.raises(ValueError,match='Incomplete'): with pytest.raises(ValueError,match='Incomplete'):
asyncio.run(call) 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'<p id="p{i}">Plain source {i}.</p>') 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] == '已完成。'