Preserve semantic note layouts and checkpoint on provider quota failures
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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/`,未推送书籍文件。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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] == '已完成。'
|
||||||
|
|||||||
Reference in New Issue
Block a user