Normalize Chinese Part ordinals and support resumable complete-book translation

This commit is contained in:
Kai Tan
2026-10-08 08:32:05 +08:00
parent 498b5d09ce
commit 644e93b874
12 changed files with 186 additions and 20 deletions
+36 -13
View File
@@ -2,7 +2,11 @@
import hashlib
import json
import re
import copy
from pathlib import Path
from src.common.utils import setup_logger
logger = setup_logger('glossary')
def source_digest(entries):
@@ -101,12 +105,16 @@ async def prepare_glossary(entries, llm, path, override=None, auto=True, chunk_c
"""Scan the whole book before translation; never race glossary updates."""
path = Path(path)
digest = source_digest(entries)
terms, conflicts, start = [], [], 0
if override:
return BookGlossary.load(override)
if path.exists():
data = json.loads(path.read_text())
if data.get('source_digest') == digest:
return BookGlossary(data['terms'])
if data.get('scan_complete', True):
return BookGlossary(data['terms'])
if data.get('chunk_chars') == chunk_chars and data.get('model') == getattr(llm, 'model', ''):
terms, conflicts, start = data['terms'], data.get('conflicts', []), data.get('next_chunk', 0)
if not auto:
return BookGlossary()
chunks, current, size = [], [], 0
@@ -116,8 +124,9 @@ async def prepare_glossary(entries, llm, path, override=None, auto=True, chunk_c
current.append(entry.original_text); size += len(entry.original_text)
if current:
chunks.append('\n'.join(current))
terms, conflicts = [], []
for chunk in chunks:
path.parent.mkdir(parents=True, exist_ok=True)
for index in range(start, len(chunks)):
chunk = chunks[index]
prompt = ('Extract recurring named people, organizations and specialist terminology. '
'Resolve transliterations across Japanese/English/Chinese. Return only JSON '
'{"terms":[{"source":"English name","target":"Chinese canonical name",'
@@ -127,21 +136,35 @@ async def prepare_glossary(entries, llm, path, override=None, auto=True, chunk_c
json.dumps(terms, ensure_ascii=False) + '\nExcerpt:\n' + chunk)
response = await llm.raw_chat_completion('You are a book terminology editor.', prompt)
response = re.sub(r'^```(?:json)?\s*|\s*```$', '', response.strip())
proposals = BookGlossary(json.loads(response)['terms'])
for term in proposals.terms:
# Validate proposals individually: a contradictory model suggestion must
# not discard the already frozen terms or stop the entire book scan.
for proposal in json.loads(response)['terms']:
try:
term = BookGlossary([proposal]).terms[0]
except (ValueError, AttributeError) as exc:
conflicts.append({'proposal': proposal, 'reason': str(exc)})
continue
aliases = {a.casefold() for a in [term['source']] + term.get('aliases', [])}
existing = next((t for t in terms if aliases & {a.casefold() for a in [t['source']] + t.get('aliases', [])}), None)
if existing and existing['target'] != term['target']:
conflicts.append({'existing': existing, 'proposal': term})
continue
candidate = copy.deepcopy(terms)
if existing:
existing['aliases'] = sorted(set(existing.get('aliases', []) + [term['source']] + term.get('aliases', [])) - {existing['source']})
candidate[terms.index(existing)]['aliases'] = sorted(set(existing.get('aliases', []) + [term['source']] + term.get('aliases', [])) - {existing['source']})
else:
terms.append(term)
BookGlossary(terms) # Fail on alias collisions before saving.
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps({'source_digest': digest, 'terms': terms, 'conflicts': conflicts},
ensure_ascii=False, indent=2), encoding='utf-8')
temp.replace(path)
candidate.append(term)
try:
BookGlossary(candidate)
except ValueError as exc:
conflicts.append({'proposal': term, 'reason': str(exc)})
continue
terms = candidate
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps({'source_digest': digest, 'terms': terms, 'conflicts': conflicts,
'scan_complete': index + 1 == len(chunks), 'next_chunk': index + 1,
'chunk_chars': chunk_chars, 'model': getattr(llm, 'model', '')},
ensure_ascii=False, indent=2), encoding='utf-8')
temp.replace(path)
logger.info('Terminology scan %s/%s: %s terms, %s conflicts recorded', index + 1, len(chunks), len(terms), len(conflicts))
return BookGlossary(terms)