feat: Release v0.10 - Modular Architecture & External Config
- Refactor codebase into src/ (preprocessing, translation, assembly) - Add pipeline/ scripts for individual stages - Externalize configuration to config/config.yaml - Fix Cover Image preservation - Update documentation and manuals
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"translation": {
|
||||
"system": "你是一位精通中英文的专业翻译家。你的任务是将英文书籍内容翻译成中文。\n\n【核心要求】\n1. 准确传达原文含义,语言流畅自然,符合中文阅读习惯。\n2. 保持原文的风格和语气。\n\n【格式要求 - 极其重要】\n1. 每行格式:#N: 译文(N是行号,必须原样保留)\n2. 输入多少行,输出必须是相同数量的行\n3. **占位符规则**:原文中的 φXφ 和 φ/Xφ 标记必须原样保留在译文中\n - 成对标记:\"φ1φBoldφ/1φ\" → \"φ1φ粗体φ/1φ\"\n - 单体标记:\"φ2φ\" 表示公式或符号,保持位置不变\n - 尾注锚点:文末的 \"φ3φ\" 是超链接,必须保留\n\n【禁止事项】\n- 禁止修改 #N: 行号\n- 禁止删除或修改任何 φXφ 标记\n- 禁止添加解释或注释\n- 禁止合并或拆分行",
|
||||
"user_template": "请翻译以下段落(务必原样保留所有 φnφ 格式标记):\n\n{{content}}"
|
||||
},
|
||||
"glossary_extraction": {
|
||||
"system": "你是一位资深的文学编辑和领域专家。你的任务是分析书籍样本,提取关键术语并制定统一的译名表。",
|
||||
"user_template": "请阅读以下书籍片段(包含前言和正文采样)。\n\n任务:\n1. 识别文中出现的人名(如 'Masa', 'Steve Jobs')、地名、机构名。\n2. 识别特定的行业术语或关键概念。\n3. 为上述词汇提供标准的中文译名。如果像 'Masa' 这样的昵称有对应的全名(如孙正义),请务必使用全名。\n\n请以 JSON 格式输出,格式如下:\n{\n \"Masa\": \"孙正义\",\n \"Apple\": \"苹果公司\",\n ...\n}\n\n书籍片段:\n\n{{content}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
from bs4 import BeautifulSoup
|
||||
import zipfile
|
||||
|
||||
def debug_clean():
|
||||
epub_path = "input/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao).epub"
|
||||
file_in_zip = "OEBPS/c9.xhtml"
|
||||
|
||||
with zipfile.ZipFile(epub_path, 'r') as zf:
|
||||
content = zf.read(file_in_zip).decode('utf-8')
|
||||
|
||||
print(f"Original Head length: {len(content)}")
|
||||
if "<head>" in content:
|
||||
print("Original has <head>")
|
||||
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
cleaned = str(soup)
|
||||
|
||||
print(f"Cleaned Head length: {len(cleaned)}")
|
||||
if "<head>" in cleaned:
|
||||
print("Cleaned has <head>")
|
||||
start = cleaned.find("<head>")
|
||||
end = cleaned.find("</head>")
|
||||
print(f"Cleaned Head content: {cleaned[start:end+7]}")
|
||||
else:
|
||||
print("Cleaned MISSING <head>")
|
||||
# check for self-closing head
|
||||
if "<head/>" in cleaned:
|
||||
print("Cleaned has <head/> (empty)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_clean()
|
||||
@@ -0,0 +1,141 @@
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from src.epub_cleaner import EpubCleaner
|
||||
from src.book_profiler import BookProfiler
|
||||
from src.fine_grained_extractor import FineGrainedExtractor
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.translator import Translator
|
||||
from src.llm_client import LLMClient
|
||||
from src.backfill_engine import BackfillEngine
|
||||
from src.bilingual_builder import BilingualBuilder
|
||||
from src.data_model import BookStructure
|
||||
from src.utils import setup_logger, ensure_directory
|
||||
from src.exceptions import EpubTranslatorError
|
||||
|
||||
logger = setup_logger("main")
|
||||
|
||||
# Unified work directory structure
|
||||
# .work/
|
||||
# ├── {book_name}/
|
||||
# │ ├── book_structure.json
|
||||
# │ ├── manifest.json
|
||||
# │ ├── assets/
|
||||
# │ └── chunks/
|
||||
|
||||
def get_work_dirs(input_path: Path) -> dict:
|
||||
"""Get work directory paths for a specific book."""
|
||||
book_name = input_path.stem
|
||||
work_root = Path(".work") / book_name
|
||||
|
||||
return {
|
||||
"root": work_root,
|
||||
"structure": work_root / "book_structure.json",
|
||||
"manifest": work_root / "manifest.json",
|
||||
"assets": work_root / "assets",
|
||||
"chunks": work_root / "chunks",
|
||||
}
|
||||
|
||||
|
||||
async def run_pipeline(args):
|
||||
input_path = Path(args.input_epub)
|
||||
output_dir = Path(args.output_dir)
|
||||
|
||||
# Get work directories for this book
|
||||
work = get_work_dirs(input_path)
|
||||
ensure_directory(work["root"])
|
||||
ensure_directory(output_dir)
|
||||
|
||||
# Load Environment
|
||||
load_dotenv()
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
|
||||
if not api_key:
|
||||
logger.warning("OPENAI_API_KEY not found in .env. LLM features may fail.")
|
||||
|
||||
try:
|
||||
# 1. Preprocessing - Reuse book_structure.json if exists
|
||||
if work["structure"].exists() and not args.force_clean:
|
||||
logger.info(f"Reusing existing book_structure: {work['structure']}")
|
||||
structure = BookStructure.load(work["structure"])
|
||||
else:
|
||||
logger.info("Cleaning EPUB and generating book_structure...")
|
||||
cleaner = EpubCleaner(input_path, work["root"])
|
||||
book_structure_json = cleaner.clean()
|
||||
structure = BookStructure.load(book_structure_json)
|
||||
|
||||
# 2. Extraction
|
||||
extractor = FineGrainedExtractor()
|
||||
manifest_entries = extractor.extract(structure)
|
||||
|
||||
# 3. Manifest Management
|
||||
manifest_manager = ManifestManager(work["manifest"])
|
||||
manifest_manager.load() # Load existing if any
|
||||
manifest_manager.add_entries(manifest_entries)
|
||||
manifest_manager.save()
|
||||
|
||||
# 4. Translation
|
||||
if not args.skip_translation:
|
||||
if not api_key:
|
||||
logger.error("Cannot translate without API Key. Use --skip-translation to test pipeline.")
|
||||
sys.exit(1)
|
||||
|
||||
llm_client = LLMClient(api_key=api_key, base_url=base_url, model=args.model)
|
||||
|
||||
# Profiling
|
||||
profiler = BookProfiler(llm_client)
|
||||
profile = await profiler.analyze(manifest_manager.entries)
|
||||
logger.info(f"Book Profile: {profile}")
|
||||
|
||||
# Translation
|
||||
translator = Translator(llm_client)
|
||||
await translator.translate(manifest_manager.entries, profile)
|
||||
manifest_manager.save()
|
||||
|
||||
await llm_client.close()
|
||||
else:
|
||||
logger.info("Skipping translation step.")
|
||||
|
||||
# 5. Backfill
|
||||
backfiller = BackfillEngine()
|
||||
updated_structure = backfiller.backfill(structure, manifest_manager.entries, mode=args.mode)
|
||||
|
||||
# 6. Assembly - Pass original EPUB for TOC preservation
|
||||
builder = BilingualBuilder(work["root"], original_epub_path=input_path)
|
||||
output_filename = f"bilingual_{input_path.name}"
|
||||
output_path = output_dir / output_filename
|
||||
|
||||
created_epub = builder.build(updated_structure, output_path)
|
||||
|
||||
logger.info(f"Pipeline completed! Output: {created_epub}")
|
||||
|
||||
except EpubTranslatorError as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.critical(f"Unexpected error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="EPUB Bilingual Translator")
|
||||
parser.add_argument("input_epub", help="Path to the input EPUB file")
|
||||
parser.add_argument("--output-dir", default="output", help="Directory for output files")
|
||||
parser.add_argument("--model", default="gpt-3.5-turbo", help="LLM Model to use")
|
||||
parser.add_argument("--mode", default="bilingual", choices=["bilingual", "target_only"], help="Output mode")
|
||||
parser.add_argument("--skip-translation", action="store_true", help="Skip LLM translation (for testing)")
|
||||
parser.add_argument("--force-clean", action="store_true", help="Force re-clean EPUB even if book_structure exists")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
asyncio.run(run_pipeline(args))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.data_model import BookStructure, ManifestEntry
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.backfill_engine import BackfillEngine
|
||||
from src.bilingual_builder import BilingualBuilder
|
||||
from src.utils import setup_logger
|
||||
|
||||
logger = setup_logger("build_final")
|
||||
|
||||
def build_final():
|
||||
BOOK_NAME = "Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao)"
|
||||
WORK_DIR = Path(f".work/{BOOK_NAME}")
|
||||
MANIFEST_PATH = WORK_DIR / "manifest.json"
|
||||
STRUCTURE_PATH = WORK_DIR / "book_structure.json"
|
||||
OUTPUT_EPUB = Path("output/final_verification.epub")
|
||||
INPUT_EPUB = Path(f"input/{BOOK_NAME}.epub")
|
||||
|
||||
if not MANIFEST_PATH.exists() or not STRUCTURE_PATH.exists():
|
||||
logger.error("Missing manifest or structure. Run translation first.")
|
||||
return
|
||||
|
||||
# 1. Load Data
|
||||
logger.info("Loading structure and manifest...")
|
||||
structure = BookStructure.load(STRUCTURE_PATH)
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
|
||||
# 2. Backfill
|
||||
logger.info("Backfilling translations...")
|
||||
backfiller = BackfillEngine()
|
||||
structure = backfiller.backfill(structure, manager.entries, mode="bilingual")
|
||||
|
||||
# 3. Build
|
||||
logger.info(f"Building final EPUB to {OUTPUT_EPUB}...")
|
||||
builder = BilingualBuilder(WORK_DIR, original_epub_path=INPUT_EPUB)
|
||||
builder.build(structure, OUTPUT_EPUB)
|
||||
|
||||
logger.info("Build complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
build_final()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print(f"CRITICAL ERROR: {e}")
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Placeholder Backfill Test Script
|
||||
Tests placeholder restoration on translated entries.
|
||||
|
||||
Usage:
|
||||
python scripts/test_backfill.py --chapter 38
|
||||
python scripts/test_backfill.py --chapter 38 --limit 10
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.format_restorer import FormatRestorer
|
||||
|
||||
# Configuration - Use unified .work directory
|
||||
BOOK_NAME = "Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao)"
|
||||
WORK_DIR = Path(f".work/{BOOK_NAME}")
|
||||
MANIFEST_PATH = WORK_DIR / "manifest.json"
|
||||
|
||||
|
||||
def build_toc_from_manifest(manager: ManifestManager) -> list:
|
||||
"""Build TOC from manifest entries."""
|
||||
files = {}
|
||||
for entry in manager.entries:
|
||||
fp = entry.file_path
|
||||
if fp not in files:
|
||||
files[fp] = {'count': 0, 'first_text': ''}
|
||||
files[fp]['count'] += 1
|
||||
if not files[fp]['first_text'] and entry.original_text:
|
||||
files[fp]['first_text'] = entry.original_text[:40].replace('\n', ' ')
|
||||
|
||||
toc = []
|
||||
for i, (fp, info) in enumerate(sorted(files.items()), 1):
|
||||
toc.append({
|
||||
'index': i,
|
||||
'href': fp,
|
||||
'title': info['first_text'] or f"File {i}",
|
||||
'paragraphs': info['count']
|
||||
})
|
||||
return toc
|
||||
|
||||
|
||||
def get_chapter_entries(manager: ManifestManager, chapter_index: int) -> tuple:
|
||||
"""Get entries for a specific chapter by index."""
|
||||
toc = build_toc_from_manifest(manager)
|
||||
|
||||
if chapter_index < 1 or chapter_index > len(toc):
|
||||
print(f"错误: 章节编号 {chapter_index} 无效 (范围: 1-{len(toc)})")
|
||||
return None, None
|
||||
|
||||
chapter = toc[chapter_index - 1]
|
||||
href = chapter['href']
|
||||
|
||||
entries = [e for e in manager.entries if e.file_path == href]
|
||||
return chapter, entries
|
||||
|
||||
|
||||
def test_backfill(chapter: dict, entries: list, limit: int = None):
|
||||
"""Test placeholder restoration for a chapter."""
|
||||
|
||||
print(f"\n" + "=" * 70)
|
||||
print(f"占位符回填测试 - 章节 #{chapter['index']}: {chapter['title'][:40]}...")
|
||||
print("=" * 70)
|
||||
|
||||
# Filter entries with translation and placeholders
|
||||
translated = [e for e in entries if e.translated_text]
|
||||
with_placeholders = [e for e in translated if e.placeholders and len(e.placeholders) > 0]
|
||||
|
||||
print(f"\n统计:")
|
||||
print(f" 总段落: {len(entries)}")
|
||||
print(f" 已翻译: {len(translated)}")
|
||||
print(f" 有占位符: {len(with_placeholders)}")
|
||||
|
||||
if not translated:
|
||||
print("\n⚠️ 该章节没有已翻译的内容!")
|
||||
return
|
||||
|
||||
# Test restoration
|
||||
restorer = FormatRestorer()
|
||||
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
results = []
|
||||
|
||||
test_entries = with_placeholders[:limit] if limit else with_placeholders
|
||||
|
||||
print(f"\n测试 {len(test_entries)} 个带占位符的段落:")
|
||||
print("-" * 70)
|
||||
|
||||
for i, entry in enumerate(test_entries, 1):
|
||||
original = entry.original_text
|
||||
translated = entry.translated_text
|
||||
placeholders = entry.placeholders
|
||||
|
||||
# Get non-internal placeholders
|
||||
visible_ph = {k: v for k, v in placeholders.items() if not k.startswith('_')}
|
||||
|
||||
# Perform restoration
|
||||
restored, success = restorer.restore(translated, placeholders)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
status = "✅"
|
||||
else:
|
||||
fail_count += 1
|
||||
status = "❌"
|
||||
|
||||
results.append({
|
||||
'index': i,
|
||||
'entry_id': entry.entry_id,
|
||||
'original': original,
|
||||
'translated': translated,
|
||||
'restored': restored,
|
||||
'placeholders': visible_ph,
|
||||
'success': success
|
||||
})
|
||||
|
||||
# Print summary
|
||||
print(f"\n[{i}] {status} {entry.entry_id[-40:]}")
|
||||
print(f" 占位符: {list(visible_ph.keys())}")
|
||||
print(f" 原文: {original[:50]}...")
|
||||
print(f" 译文: {translated[:50]}...")
|
||||
|
||||
if not success:
|
||||
print(f" 还原: {restored[:50]}...")
|
||||
# Show what placeholders are missing
|
||||
missing = []
|
||||
for k in visible_ph.keys():
|
||||
if k.isdigit():
|
||||
if f"φ{k}φ" not in translated and f"φ/{k}φ" not in translated:
|
||||
missing.append(k)
|
||||
if missing:
|
||||
print(f" 缺失: {missing}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
print(f"测试结果汇总")
|
||||
print("=" * 70)
|
||||
print(f" 成功: {success_count}/{len(test_entries)}")
|
||||
print(f" 失败: {fail_count}/{len(test_entries)}")
|
||||
|
||||
if fail_count > 0:
|
||||
print(f"\n失败案例详情:")
|
||||
for r in results:
|
||||
if not r['success']:
|
||||
print(f"\n [{r['index']}] {r['entry_id'][-50:]}")
|
||||
print(f" 原文: {r['original'][:60]}...")
|
||||
print(f" 译文: {r['translated'][:60]}...")
|
||||
print(f" 还原: {r['restored'][:60]}...")
|
||||
print(f" 占位符: {r['placeholders']}")
|
||||
|
||||
# Also test entries without visible placeholders (only _prefix/_suffix)
|
||||
prefix_suffix_only = [e for e in translated
|
||||
if e.placeholders
|
||||
and all(k.startswith('_') for k in e.placeholders.keys())]
|
||||
|
||||
if prefix_suffix_only:
|
||||
print(f"\n\n额外测试: 只有 _prefix/_suffix 的段落 ({len(prefix_suffix_only)} 个)")
|
||||
print("-" * 70)
|
||||
|
||||
ps_success = 0
|
||||
ps_fail = 0
|
||||
|
||||
for entry in prefix_suffix_only[:5]: # Test first 5
|
||||
restored, success = restorer.restore(entry.translated_text, entry.placeholders)
|
||||
if success:
|
||||
ps_success += 1
|
||||
status = "✅"
|
||||
else:
|
||||
ps_fail += 1
|
||||
status = "❌"
|
||||
|
||||
print(f" {status} {entry.entry_id[-40:]}")
|
||||
if '_prefix' in entry.placeholders:
|
||||
print(f" _prefix: {entry.placeholders['_prefix'][:30]}...")
|
||||
if '_suffix' in entry.placeholders:
|
||||
print(f" _suffix: {entry.placeholders['_suffix'][:30]}...")
|
||||
|
||||
print(f"\n 结果: {ps_success}/{min(5, len(prefix_suffix_only))} 成功")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="测试占位符回填")
|
||||
parser.add_argument("--chapter", "-c", type=int, required=True, help="章节编号")
|
||||
parser.add_argument("--limit", "-l", type=int, default=20, help="测试数量限制 (默认20)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"错误: Manifest 不存在: {MANIFEST_PATH}")
|
||||
return
|
||||
|
||||
# Load manifest
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
print(f"已加载 manifest: {len(manager.entries)} 条目")
|
||||
|
||||
# Get chapter
|
||||
chapter, entries = get_chapter_entries(manager, args.chapter)
|
||||
if not chapter:
|
||||
return
|
||||
|
||||
# Test backfill
|
||||
test_backfill(chapter, entries, args.limit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
import asyncio
|
||||
import os
|
||||
import httpx
|
||||
|
||||
async def test_conn():
|
||||
print(f"HTTP_PROXY: {os.environ.get('http_proxy')}")
|
||||
print(f"HTTPS_PROXY: {os.environ.get('https_proxy')}")
|
||||
print(f"ALL_PROXY: {os.environ.get('all_proxy')}")
|
||||
|
||||
url = "https://api.gpt.ge/v1/models"
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('V3_API_KEY')}"}
|
||||
|
||||
print(f"Connecting to {url}...")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Headers: {resp.headers}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
asyncio.run(test_conn())
|
||||
@@ -0,0 +1,40 @@
|
||||
import asyncio
|
||||
import os
|
||||
import httpx
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
async def test_openai():
|
||||
proxy_url = os.environ.get("http_proxy")
|
||||
print(f"Using proxy: {proxy_url}")
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
proxy=proxy_url,
|
||||
timeout=30.0,
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
client = AsyncOpenAI(
|
||||
base_url="https://api.gpt.ge/v1",
|
||||
api_key=os.environ.get("V3_API_KEY"),
|
||||
http_client=http_client
|
||||
)
|
||||
|
||||
print("Sending request...")
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-3.5-turbo",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
max_tokens=5
|
||||
)
|
||||
print(f"Response: {response.choices[0].message.content}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
await http_client.aclose()
|
||||
|
||||
if __name__ == "__main__":
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
asyncio.run(test_openai())
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script to show chunk content and test short ID strategy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.llm_client import LLMClient
|
||||
from src.data_model import ManifestEntry
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Configuration
|
||||
MANIFEST_PATH = Path("cache/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao)_manifest.json")
|
||||
API_KEY = os.getenv("V3_API_KEY")
|
||||
BASE_URL = "https://api.gpt.ge/v1"
|
||||
MODEL = "gemini-3-flash-preview"
|
||||
EXTRA_HEADERS = {"x-foo": "true"}
|
||||
|
||||
|
||||
def show_current_chunk_format():
|
||||
"""Show current chunk format (problematic)."""
|
||||
print("\n" + "="*60)
|
||||
print("当前 Chunk 格式 (有问题)")
|
||||
print("="*60)
|
||||
|
||||
# Load manifest
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
|
||||
# Get sample entries with placeholders
|
||||
samples = [e for e in manager.entries if e.placeholders][:3]
|
||||
|
||||
print("\n发送给 LLM 的格式 (当前):")
|
||||
print("-"*60)
|
||||
for item in samples:
|
||||
context = item.context or "BODY"
|
||||
print(f"{item.entry_id} [{context}] {item.original_text[:50]}...")
|
||||
|
||||
print("\n问题分析:")
|
||||
print(" 1. entry_id 太长 (包含 UUID): 容易被 LLM 截断或修改")
|
||||
print(" 2. [BODY] context 没必要发送")
|
||||
print(" 3. 依赖 LLM 精确复制长 ID,不可靠")
|
||||
|
||||
|
||||
def show_proposed_chunk_format():
|
||||
"""Show proposed short ID chunk format."""
|
||||
print("\n" + "="*60)
|
||||
print("建议的 Chunk 格式 (短 ID)")
|
||||
print("="*60)
|
||||
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
|
||||
samples = [e for e in manager.entries if e.placeholders][:5]
|
||||
|
||||
print("\n发送给 LLM 的格式 (建议):")
|
||||
print("-"*60)
|
||||
|
||||
# Build with short IDs
|
||||
id_map = {} # short_id -> entry_id
|
||||
for i, item in enumerate(samples, 1):
|
||||
short_id = f"#{i}"
|
||||
id_map[short_id] = item.entry_id
|
||||
text = item.original_text[:60]
|
||||
print(f"{short_id}: {text}...")
|
||||
|
||||
print("\n期望 LLM 返回的格式:")
|
||||
print("-"*60)
|
||||
print("#1: φ1φpenguinrandomhouse.com(保持不翻译)")
|
||||
print("#2: 该产品在欧盟的产品安全授权代表为 φ1φPenguin Random House Irelandφ/1φ...")
|
||||
print("#3: φ1φ献辞")
|
||||
print("#4: φ1φ题记")
|
||||
print("#5: φ1φ作者说明")
|
||||
|
||||
print("\n优势:")
|
||||
print(" 1. 短 ID (#1, #2...) 不会被 LLM 弄乱")
|
||||
print(" 2. 去掉了无用的 context 标签")
|
||||
print(" 3. 解析更可靠:用正则 ^#(\\d+): 匹配")
|
||||
print(" 4. ID 映射表保留在代码中,用于还原")
|
||||
|
||||
print("\n映射表 (代码内保留):")
|
||||
for short_id, full_id in id_map.items():
|
||||
print(f" {short_id} -> {full_id[:50]}...")
|
||||
|
||||
|
||||
async def test_short_id_translation():
|
||||
"""Test translation with short ID format."""
|
||||
print("\n" + "="*60)
|
||||
print("测试短 ID 翻译")
|
||||
print("="*60)
|
||||
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
|
||||
# Get 5 entries with varied content
|
||||
samples = [e for e in manager.entries if len(e.original_text) > 20][:5]
|
||||
|
||||
# Build prompt with short IDs
|
||||
id_map = {}
|
||||
lines = []
|
||||
for i, item in enumerate(samples, 1):
|
||||
short_id = f"#{i}"
|
||||
id_map[short_id] = item.entry_id
|
||||
text = item.original_text.replace('\n', ' ').strip()
|
||||
lines.append(f"{short_id}: {text}")
|
||||
|
||||
user_prompt = "\n".join(lines)
|
||||
|
||||
print("\n发送给 LLM 的 Prompt:")
|
||||
print("-"*60)
|
||||
print(user_prompt)
|
||||
|
||||
# Create client
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(
|
||||
base_url=BASE_URL,
|
||||
api_key=API_KEY,
|
||||
default_headers=EXTRA_HEADERS
|
||||
)
|
||||
|
||||
system_prompt = """You are a professional English to Chinese translator.
|
||||
|
||||
Translate each line to Chinese. Keep the format:
|
||||
- Each line starts with #N: (keep this ID exactly)
|
||||
- Preserve any φXφ placeholders exactly as-is
|
||||
- Only output translations, no explanations
|
||||
|
||||
Example input:
|
||||
#1: Hello world
|
||||
#2: φ1φClick hereφ/1φ to continue
|
||||
|
||||
Example output:
|
||||
#1: 你好世界
|
||||
#2: φ1φ点击这里φ/1φ 继续"""
|
||||
|
||||
print("\n发送请求...")
|
||||
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
temperature=0.3,
|
||||
)
|
||||
raw_response = resp.choices[0].message.content.strip()
|
||||
|
||||
print("\nLLM 返回:")
|
||||
print("-"*60)
|
||||
print(raw_response)
|
||||
|
||||
# Parse with short ID
|
||||
print("\n解析结果:")
|
||||
print("-"*60)
|
||||
import re
|
||||
results = {}
|
||||
for line in raw_response.split("\n"):
|
||||
line = line.strip()
|
||||
match = re.match(r'^#(\d+):\s*(.+)$', line)
|
||||
if match:
|
||||
short_id = f"#{match.group(1)}"
|
||||
translation = match.group(2)
|
||||
if short_id in id_map:
|
||||
full_id = id_map[short_id]
|
||||
results[full_id] = translation
|
||||
print(f" {short_id} -> {translation[:40]}...")
|
||||
|
||||
print(f"\n成功解析: {len(results)}/{len(samples)}")
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def main():
|
||||
print("="*60)
|
||||
print("Chunk ID 策略分析与测试")
|
||||
print("="*60)
|
||||
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"ERROR: Manifest not found at {MANIFEST_PATH}")
|
||||
return
|
||||
|
||||
# 1. Show current format (problems)
|
||||
show_current_chunk_format()
|
||||
|
||||
# 2. Show proposed format
|
||||
show_proposed_chunk_format()
|
||||
|
||||
# 3. Test short ID translation
|
||||
if API_KEY:
|
||||
await test_short_id_translation()
|
||||
else:
|
||||
print("\n跳过测试 (V3_API_KEY 未设置)")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("分析完成")
|
||||
print("="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Translation Pipeline Debug Script
|
||||
Shows visible results at each step of the translation process.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.llm_client import LLMClient
|
||||
from src.book_profiler import BookProfiler
|
||||
from src.translator import Translator
|
||||
from src.format_restorer import FormatRestorer
|
||||
from src.data_model import ManifestEntry, BookProfile
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Configuration
|
||||
MANIFEST_PATH = Path("cache/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao)_manifest.json")
|
||||
API_KEY = os.getenv("V3_API_KEY")
|
||||
BASE_URL = "https://api.gpt.ge/v1"
|
||||
MODEL = "gemini-3-flash-preview"
|
||||
EXTRA_HEADERS = {"x-foo": "true"}
|
||||
|
||||
# Chunk size configuration
|
||||
MAX_CHUNK_SIZE = 15 # Maximum entries per chunk
|
||||
|
||||
|
||||
def group_entries_by_file(entries: list) -> dict:
|
||||
"""Group manifest entries by their source file (chapter)."""
|
||||
grouped = {}
|
||||
for entry in entries:
|
||||
file_path = entry.file_path
|
||||
if file_path not in grouped:
|
||||
grouped[file_path] = []
|
||||
grouped[file_path].append(entry)
|
||||
return grouped
|
||||
|
||||
|
||||
def create_chapter_aware_chunks(entries: list, max_size: int = MAX_CHUNK_SIZE) -> list:
|
||||
"""
|
||||
Create chunks that respect chapter boundaries.
|
||||
Returns list of (file_path, chunk_entries) tuples.
|
||||
"""
|
||||
grouped = group_entries_by_file(entries)
|
||||
chunks = []
|
||||
|
||||
for file_path, file_entries in grouped.items():
|
||||
# Split this file's entries into chunks of max_size
|
||||
for i in range(0, len(file_entries), max_size):
|
||||
chunk = file_entries[i:i + max_size]
|
||||
chunks.append((file_path, chunk))
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def get_file_type(file_path: str) -> str:
|
||||
"""Determine the type of content based on file name."""
|
||||
fname = file_path.lower()
|
||||
if any(k in fname for k in ['toc', 'contents', 'nav']):
|
||||
return 'toc'
|
||||
elif any(k in fname for k in ['title', 'cover']):
|
||||
return 'cover'
|
||||
elif any(k in fname for k in ['copyright', 'colophon']):
|
||||
return 'legal'
|
||||
elif any(k in fname for k in ['author', 'about']):
|
||||
return 'author_bio'
|
||||
elif any(k in fname for k in ['index', 'bibliography', 'endnote', 'footnote']):
|
||||
return 'reference'
|
||||
else:
|
||||
return 'body'
|
||||
|
||||
|
||||
async def step1_load_manifest():
|
||||
"""Step 1: Load manifest and show statistics."""
|
||||
print("\n" + "="*60)
|
||||
print("STEP 1: Loading Manifest")
|
||||
print("="*60)
|
||||
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
|
||||
entries = manager.entries
|
||||
untranslated = [e for e in entries if not e.translated_text]
|
||||
|
||||
print(f" Total entries: {len(entries)}")
|
||||
print(f" Untranslated: {len(untranslated)}")
|
||||
|
||||
# Show grouping by file
|
||||
grouped = group_entries_by_file(entries)
|
||||
print(f" Unique files: {len(grouped)}")
|
||||
|
||||
# Show sample entry
|
||||
if entries:
|
||||
sample = entries[0]
|
||||
print(f"\n Sample entry:")
|
||||
print(f" ID: {sample.entry_id}")
|
||||
print(f" File: {sample.file_path}")
|
||||
print(f" Original: {sample.original_text[:80]}...")
|
||||
print(f" Placeholders: {sample.placeholders}")
|
||||
|
||||
return manager
|
||||
|
||||
|
||||
async def step2_profile_book(manager: ManifestManager, llm_client: LLMClient):
|
||||
"""Step 2: Generate book profile."""
|
||||
print("\n" + "="*60)
|
||||
print("STEP 2: Generating Book Profile")
|
||||
print("="*60)
|
||||
|
||||
profiler = BookProfiler(llm_client)
|
||||
profile = await profiler.analyze(manager.entries)
|
||||
|
||||
print(f" Title: {profile.title}")
|
||||
print(f" Author: {profile.author}")
|
||||
print(f" Genre: {profile.genre}")
|
||||
print(f" Keywords: {profile.keywords}")
|
||||
print(f" Style Guide: {profile.style_guide[:200]}..." if profile.style_guide else " Style Guide: (none)")
|
||||
|
||||
return profile
|
||||
|
||||
|
||||
async def step3_create_chunks(manager: ManifestManager):
|
||||
"""Step 3: Create chapter-aware chunks."""
|
||||
print("\n" + "="*60)
|
||||
print("STEP 3: Creating Chapter-Aware Chunks")
|
||||
print("="*60)
|
||||
|
||||
untranslated = [e for e in manager.entries if not e.translated_text]
|
||||
chunks = create_chapter_aware_chunks(untranslated)
|
||||
|
||||
print(f" Total chunks: {len(chunks)}")
|
||||
|
||||
# Show chunk distribution
|
||||
print(f"\n Chunk distribution by file type:")
|
||||
type_counts = {}
|
||||
for file_path, chunk_entries in chunks:
|
||||
ftype = get_file_type(file_path)
|
||||
type_counts[ftype] = type_counts.get(ftype, 0) + 1
|
||||
|
||||
for ftype, count in sorted(type_counts.items()):
|
||||
print(f" {ftype}: {count} chunks")
|
||||
|
||||
# Show first 3 chunks
|
||||
print(f"\n First 3 chunks:")
|
||||
for i, (file_path, chunk_entries) in enumerate(chunks[:3]):
|
||||
ftype = get_file_type(file_path)
|
||||
print(f" [{i}] {file_path} ({ftype}): {len(chunk_entries)} entries")
|
||||
if chunk_entries:
|
||||
print(f" First: {chunk_entries[0].original_text[:50]}...")
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
async def step4_translate_sample(chunks: list, llm_client: LLMClient, profile: BookProfile):
|
||||
"""Step 4: Translate a sample chunk and show results."""
|
||||
print("\n" + "="*60)
|
||||
print("STEP 4: Translating Sample Chunk")
|
||||
print("="*60)
|
||||
|
||||
if not chunks:
|
||||
print(" No chunks to translate!")
|
||||
return
|
||||
|
||||
# Pick a proper body chapter (skip first few files which are usually cover/copyright/toc)
|
||||
sample_chunk = None
|
||||
skip_prefixes = ['cM', 'c9', 'c18'] # Cover, title, contents pages
|
||||
for file_path, chunk_entries in chunks:
|
||||
# Skip non-body files and known cover/toc files
|
||||
ftype = get_file_type(file_path)
|
||||
fname = Path(file_path).stem
|
||||
if ftype == 'body' and fname not in skip_prefixes and len(chunk_entries) > 3:
|
||||
sample_chunk = (file_path, chunk_entries[:5]) # Limit to 5 entries for demo
|
||||
break
|
||||
|
||||
if not sample_chunk:
|
||||
# Fallback to any body chunk
|
||||
for file_path, chunk_entries in chunks:
|
||||
if get_file_type(file_path) == 'body':
|
||||
sample_chunk = (file_path, chunk_entries[:5])
|
||||
break
|
||||
|
||||
if not sample_chunk:
|
||||
sample_chunk = chunks[0]
|
||||
sample_chunk = (sample_chunk[0], sample_chunk[1][:5])
|
||||
|
||||
file_path, entries = sample_chunk
|
||||
ftype = get_file_type(file_path)
|
||||
|
||||
print(f" Selected chunk: {file_path} ({ftype})")
|
||||
print(f" Entries: {len(entries)}")
|
||||
|
||||
# Show entries before translation
|
||||
print(f"\n === Before Translation ===")
|
||||
for i, entry in enumerate(entries):
|
||||
print(f" [{i}] {entry.entry_id}")
|
||||
print(f" Original: {entry.original_text[:60]}...")
|
||||
if entry.placeholders:
|
||||
print(f" Placeholders: {list(entry.placeholders.keys())}")
|
||||
|
||||
# Translate
|
||||
print(f"\n Translating...")
|
||||
results = await llm_client.translate_chunk(
|
||||
entries,
|
||||
instruction=profile.style_guide,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
# Apply results and show
|
||||
print(f"\n === After Translation ===")
|
||||
restorer = FormatRestorer()
|
||||
for i, entry in enumerate(entries):
|
||||
if entry.entry_id in results:
|
||||
translated = results[entry.entry_id]
|
||||
entry.translated_text = translated
|
||||
|
||||
print(f" [{i}] {entry.entry_id}")
|
||||
print(f" Original: {entry.original_text[:50]}...")
|
||||
print(f" Translated: {translated[:50]}...")
|
||||
|
||||
# Restore format
|
||||
if entry.placeholders:
|
||||
restored, success = restorer.restore(translated, entry.placeholders)
|
||||
print(f" Restored OK: {success}")
|
||||
if not success:
|
||||
print(f" Restored: {restored[:50]}...")
|
||||
else:
|
||||
print(f" [{i}] MISSING: {entry.entry_id}")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
async def step5_test_placeholders(manager: ManifestManager, llm_client: LLMClient, profile: BookProfile):
|
||||
"""Step 5: Test placeholder handling with entries that have placeholders."""
|
||||
print("\n" + "="*60)
|
||||
print("STEP 5: Testing Placeholder Handling")
|
||||
print("="*60)
|
||||
|
||||
# Find entries with placeholders
|
||||
entries_with_ph = [e for e in manager.entries if e.placeholders and len(e.placeholders) > 1]
|
||||
|
||||
print(f" Entries with placeholders: {len(entries_with_ph)}")
|
||||
|
||||
if not entries_with_ph:
|
||||
print(" No entries with placeholders found!")
|
||||
return
|
||||
|
||||
# Pick 5 diverse samples
|
||||
samples = entries_with_ph[:5]
|
||||
|
||||
print(f"\n === Selected Samples ({len(samples)}) ===")
|
||||
for i, entry in enumerate(samples):
|
||||
ph_keys = [k for k in entry.placeholders.keys() if not k.startswith('_')]
|
||||
print(f" [{i}] {entry.entry_id}")
|
||||
print(f" Original: {entry.original_text[:60]}...")
|
||||
print(f" Placeholders: {ph_keys}")
|
||||
|
||||
# Translate
|
||||
print(f"\n Translating {len(samples)} entries with placeholders...")
|
||||
results = await llm_client.translate_chunk(
|
||||
samples,
|
||||
instruction=profile.style_guide,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
# Show results with restoration
|
||||
print(f"\n === Translation Results ===")
|
||||
restorer = FormatRestorer()
|
||||
success_count = 0
|
||||
|
||||
for i, entry in enumerate(samples):
|
||||
print(f"\n [{i}] {entry.entry_id}")
|
||||
print(f" Original: {entry.original_text[:50]}...")
|
||||
|
||||
if entry.entry_id in results:
|
||||
translated = results[entry.entry_id]
|
||||
print(f" Translated: {translated[:50]}...")
|
||||
|
||||
# Check if placeholders are preserved
|
||||
ph_keys = [k for k in entry.placeholders.keys() if not k.startswith('_')]
|
||||
preserved = all(f"φ{k}φ" in translated or f"φ/{k}φ" in translated for k in ph_keys if k.isdigit())
|
||||
print(f" PH Preserved: {preserved}")
|
||||
|
||||
# Restore format
|
||||
restored, success = restorer.restore(translated, entry.placeholders)
|
||||
print(f" Restore OK: {success}")
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
print(f" Restored: {restored[:50]}...")
|
||||
else:
|
||||
print(f" MISSING from results!")
|
||||
|
||||
print(f"\n Summary: {success_count}/{len(samples)} restored successfully")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all steps."""
|
||||
print("="*60)
|
||||
print("TRANSLATION PIPELINE DEBUG")
|
||||
print("="*60)
|
||||
|
||||
if not API_KEY:
|
||||
print("ERROR: V3_API_KEY not found in .env")
|
||||
return
|
||||
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"ERROR: Manifest not found at {MANIFEST_PATH}")
|
||||
print("Run the main pipeline first to generate the manifest.")
|
||||
return
|
||||
|
||||
# Initialize LLM client
|
||||
llm_client = LLMClient(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
model=MODEL,
|
||||
extra_headers=EXTRA_HEADERS
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: Load manifest
|
||||
manager = await step1_load_manifest()
|
||||
|
||||
# Step 2: Profile book
|
||||
profile = await step2_profile_book(manager, llm_client)
|
||||
|
||||
# Step 3: Create chunks
|
||||
chunks = await step3_create_chunks(manager)
|
||||
|
||||
# Step 4: Translate sample (simple text)
|
||||
await step4_translate_sample(chunks, llm_client, profile)
|
||||
|
||||
# Step 5: Test placeholders
|
||||
await step5_test_placeholders(manager, llm_client, profile)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("DEBUG COMPLETE")
|
||||
print("="*60)
|
||||
|
||||
finally:
|
||||
await llm_client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Chapter Translation Test Script
|
||||
Translate a complete chapter to test the full pipeline.
|
||||
|
||||
Usage:
|
||||
python scripts/translate_chapter.py --show-toc # 显示章节目录
|
||||
python scripts/translate_chapter.py --chapter 5 # 翻译第5章
|
||||
python scripts/translate_chapter.py --chapter 5 --test # 测试模式,只翻译前2个chunk
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.llm_client import LLMClient
|
||||
from src.book_profiler import BookProfiler
|
||||
from src.format_restorer import FormatRestorer
|
||||
from src.data_model import ManifestEntry, BookStructure
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Configuration - Use unified .work directory
|
||||
BOOK_NAME = "Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao)"
|
||||
WORK_DIR = Path(f".work/{BOOK_NAME}")
|
||||
MANIFEST_PATH = WORK_DIR / "manifest.json"
|
||||
STRUCTURE_PATH = WORK_DIR / "book_structure.json"
|
||||
CHUNK_DIR = WORK_DIR / "chunks"
|
||||
|
||||
API_KEY = os.getenv("V3_API_KEY")
|
||||
BASE_URL = "https://api.gpt.ge/v1"
|
||||
MODEL = "gpt-4o-mini" # "gemini-3-flash-preview"
|
||||
EXTRA_HEADERS = {"x-foo": "true"}
|
||||
|
||||
# Chunk config - around 5000 chars per chunk
|
||||
CHUNK_SIZE_CHARS = 5000
|
||||
|
||||
|
||||
def load_toc_from_structure() -> list:
|
||||
"""Load TOC from book structure for readable chapter names."""
|
||||
if not STRUCTURE_PATH.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
structure = BookStructure.load(STRUCTURE_PATH)
|
||||
# Build TOC from spine order with chapter titles
|
||||
toc = []
|
||||
for i, item_id in enumerate(structure.spine, 1):
|
||||
if item_id in structure.resources:
|
||||
resource = structure.resources[item_id]
|
||||
href = resource.href
|
||||
|
||||
# Try to extract title from content
|
||||
title = extract_title_from_html(resource.content) if resource.content else None
|
||||
|
||||
toc.append({
|
||||
'index': i,
|
||||
'item_id': item_id,
|
||||
'href': href,
|
||||
'title': title or f"Chapter {i}"
|
||||
})
|
||||
return toc
|
||||
except Exception as e:
|
||||
print(f"警告: 无法加载书籍结构: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def extract_title_from_html(html: str) -> str:
|
||||
"""Extract title from HTML content."""
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Try h1, h2, h3 in order
|
||||
for tag in ['h1', 'h2', 'h3']:
|
||||
elem = soup.find(tag)
|
||||
if elem:
|
||||
return elem.get_text().strip()[:50]
|
||||
|
||||
# Try first paragraph
|
||||
p = soup.find('p')
|
||||
if p:
|
||||
text = p.get_text().strip()[:50]
|
||||
if text:
|
||||
return text + "..."
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_toc_from_manifest(manager: ManifestManager) -> list:
|
||||
"""Build TOC from manifest entries."""
|
||||
files = {}
|
||||
for entry in manager.entries:
|
||||
fp = entry.file_path
|
||||
if fp not in files:
|
||||
files[fp] = {
|
||||
'count': 0,
|
||||
'first_text': '',
|
||||
'total_chars': 0
|
||||
}
|
||||
files[fp]['count'] += 1
|
||||
files[fp]['total_chars'] += len(entry.original_text)
|
||||
if not files[fp]['first_text'] and entry.original_text:
|
||||
files[fp]['first_text'] = entry.original_text[:40].replace('\n', ' ')
|
||||
|
||||
toc = []
|
||||
for i, (fp, info) in enumerate(sorted(files.items()), 1):
|
||||
toc.append({
|
||||
'index': i,
|
||||
'href': fp,
|
||||
'title': info['first_text'] or f"File {i}",
|
||||
'paragraphs': info['count'],
|
||||
'chars': info['total_chars']
|
||||
})
|
||||
return toc
|
||||
|
||||
|
||||
def show_toc(manager: ManifestManager):
|
||||
"""Display TOC with chapter numbers."""
|
||||
toc = build_toc_from_manifest(manager)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("章节目录 (Table of Contents)")
|
||||
print("=" * 70)
|
||||
print(f"{'#':>3} | {'段落':>5} | {'字符':>6} | 章节标题")
|
||||
print("-" * 70)
|
||||
|
||||
for item in toc:
|
||||
title = item['title'][:45] if len(item['title']) > 45 else item['title']
|
||||
print(f"{item['index']:3d} | {item['paragraphs']:5d} | {item['chars']:6d} | {title}")
|
||||
|
||||
print("-" * 70)
|
||||
print(f"共 {len(toc)} 个章节")
|
||||
print("\n用法: python scripts/translate_chapter.py --chapter <编号>")
|
||||
print("示例: python scripts/translate_chapter.py --chapter 5")
|
||||
|
||||
|
||||
def get_chapter_entries(manager: ManifestManager, chapter_index: int) -> tuple:
|
||||
"""Get entries for a specific chapter by index."""
|
||||
toc = build_toc_from_manifest(manager)
|
||||
|
||||
if chapter_index < 1 or chapter_index > len(toc):
|
||||
print(f"错误: 章节编号 {chapter_index} 无效 (范围: 1-{len(toc)})")
|
||||
return None, None
|
||||
|
||||
chapter = toc[chapter_index - 1]
|
||||
href = chapter['href']
|
||||
|
||||
entries = [e for e in manager.entries if e.file_path == href]
|
||||
return chapter, entries
|
||||
|
||||
|
||||
def create_char_based_chunks(entries: list, chunk_size: int = CHUNK_SIZE_CHARS) -> list:
|
||||
"""
|
||||
Create chunks based on character count (~5000 chars each).
|
||||
Returns list of entry lists.
|
||||
"""
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
for entry in entries:
|
||||
text_len = len(entry.original_text)
|
||||
|
||||
# If adding this entry exceeds limit and we have content, start new chunk
|
||||
if current_size + text_len > chunk_size and current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
current_chunk.append(entry)
|
||||
current_size += text_len
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
async def translate_chapter(chapter: dict, entries: list, manager: ManifestManager,
|
||||
llm_client: LLMClient, profile, test_mode: bool = False):
|
||||
"""Translate a complete chapter."""
|
||||
|
||||
print(f"\n开始翻译章节 #{chapter['index']}: {chapter['title'][:40]}...")
|
||||
print(f" 文件: {chapter['href']}")
|
||||
print(f" 总段落: {len(entries)}")
|
||||
|
||||
# Filter untranslated
|
||||
untranslated = [e for e in entries if not e.translated_text]
|
||||
print(f" 待翻译: {len(untranslated)}")
|
||||
|
||||
if not untranslated:
|
||||
print(" ✅ 该章节已全部翻译!")
|
||||
return
|
||||
|
||||
# Create character-based chunks
|
||||
chunks = create_char_based_chunks(untranslated)
|
||||
print(f" 分块: {len(chunks)} 个 Chunk (约{CHUNK_SIZE_CHARS}字符/块)")
|
||||
|
||||
if test_mode:
|
||||
print(" [测试模式] 只翻译前2个 Chunk")
|
||||
chunks = chunks[:2]
|
||||
|
||||
# Show chunk stats
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
total_chars = sum(len(e.original_text) for e in chunk)
|
||||
print(f" Chunk {i}: {len(chunk)} 段落, {total_chars} 字符")
|
||||
|
||||
# Translate
|
||||
restorer = FormatRestorer()
|
||||
total_success = 0
|
||||
total_failed = 0
|
||||
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
chunk_chars = sum(len(e.original_text) for e in chunk)
|
||||
print(f"\n 翻译 Chunk {i}/{len(chunks)} ({len(chunk)} 段, {chunk_chars} 字符)...")
|
||||
|
||||
try:
|
||||
results = await llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if hasattr(profile, 'style_guide') else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
# Apply results
|
||||
chunk_success = 0
|
||||
chunk_failed = 0
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
translated = results[entry.entry_id]
|
||||
entry.translated_text = translated
|
||||
|
||||
# Verify placeholder restoration
|
||||
if entry.placeholders:
|
||||
_, success = restorer.restore(translated, entry.placeholders)
|
||||
if success:
|
||||
chunk_success += 1
|
||||
else:
|
||||
chunk_failed += 1
|
||||
print(f" ⚠️ 占位符还原警告: {entry.entry_id[-30:]}")
|
||||
else:
|
||||
chunk_success += 1
|
||||
else:
|
||||
chunk_failed += 1
|
||||
print(f" ❌ 缺失: {entry.entry_id[-30:]}")
|
||||
|
||||
total_success += chunk_success
|
||||
total_failed += chunk_failed
|
||||
print(f" ✓ 成功: {chunk_success}, 失败: {chunk_failed}")
|
||||
|
||||
# Save after each chunk
|
||||
manager.save()
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Chunk {i} 翻译失败: {e}")
|
||||
total_failed += len(chunk)
|
||||
|
||||
print(f"\n翻译完成:")
|
||||
print(f" ✅ 成功: {total_success}")
|
||||
print(f" ❌ 失败: {total_failed}")
|
||||
|
||||
# Show sample results
|
||||
print(f"\n翻译样例 (前3段):")
|
||||
print("-" * 60)
|
||||
translated_entries = [e for e in entries if e.translated_text][:3]
|
||||
for entry in translated_entries:
|
||||
orig = entry.original_text[:40].replace('\n', ' ')
|
||||
trans = entry.translated_text[:40].replace('\n', ' ') if entry.translated_text else "(无)"
|
||||
print(f" 原: {orig}...")
|
||||
print(f" 译: {trans}...")
|
||||
print()
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="翻译指定章节")
|
||||
parser.add_argument("--show-toc", action="store_true", help="显示章节目录")
|
||||
parser.add_argument("--chapter", "-c", type=int, help="章节编号 (从1开始)")
|
||||
parser.add_argument("--test", "-t", action="store_true", help="测试模式 (只翻译前2个chunk)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"错误: Manifest 不存在: {MANIFEST_PATH}")
|
||||
print("请先运行主管道生成 manifest。")
|
||||
return
|
||||
|
||||
# Load manifest
|
||||
manager = ManifestManager(MANIFEST_PATH)
|
||||
manager.load()
|
||||
print(f"已加载 manifest: {len(manager.entries)} 条目")
|
||||
|
||||
# Show TOC
|
||||
if args.show_toc or not args.chapter:
|
||||
show_toc(manager)
|
||||
return
|
||||
|
||||
if not API_KEY:
|
||||
print("错误: V3_API_KEY 未设置")
|
||||
return
|
||||
|
||||
# Get chapter entries
|
||||
chapter, entries = get_chapter_entries(manager, args.chapter)
|
||||
if not chapter:
|
||||
return
|
||||
|
||||
# Initialize LLM client
|
||||
from src.utils import ensure_directory
|
||||
ensure_directory(CHUNK_DIR)
|
||||
|
||||
llm_client = LLMClient(
|
||||
api_key=API_KEY,
|
||||
base_url=BASE_URL,
|
||||
model=MODEL,
|
||||
extra_headers=EXTRA_HEADERS,
|
||||
chunk_dir=CHUNK_DIR
|
||||
)
|
||||
|
||||
try:
|
||||
# Generate profile
|
||||
print("\n生成书籍 Profile... (Skipping for debug)")
|
||||
# profiler = BookProfiler(llm_client)
|
||||
# profile = await profiler.analyze(manager.entries)
|
||||
# print(f" 风格: {profile.style_guide[:80] if profile.style_guide else '(无)'}...")
|
||||
|
||||
class DummyProfile:
|
||||
style_guide = "Keep technical terms. Translate accurately."
|
||||
profile = DummyProfile()
|
||||
|
||||
# Translate chapter
|
||||
await translate_chapter(chapter, entries, manager, llm_client, profile, args.test)
|
||||
|
||||
print(f"\n✅ Manifest 已保存: {MANIFEST_PATH}")
|
||||
print(f"✅ Chunk 文件保存在: {CHUNK_DIR}")
|
||||
|
||||
finally:
|
||||
await llm_client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("DEBUG: Script started execution")
|
||||
try:
|
||||
asyncio.run(main())
|
||||
print("DEBUG: Script finished execution")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
print(f"CRITICAL ERROR: {e}")
|
||||
@@ -0,0 +1,47 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.epub_cleaner import EpubCleaner
|
||||
from src.bilingual_builder import BilingualBuilder
|
||||
from src.utils import setup_logger
|
||||
from src.data_model import BookStructure
|
||||
|
||||
logger = setup_logger("verify_toc")
|
||||
|
||||
def verify_fix():
|
||||
INPUT_EPUB = Path("input/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao).epub")
|
||||
OUTPUT_EPUB = Path("output/verify_toc.epub")
|
||||
WORK_DIR = Path("tmp/verify_toc")
|
||||
|
||||
if not INPUT_EPUB.exists():
|
||||
logger.error(f"Input not found: {INPUT_EPUB}")
|
||||
return
|
||||
|
||||
# Clean
|
||||
logger.info("Step 1: Cleaning EPUB...")
|
||||
cleaner = EpubCleaner(INPUT_EPUB, WORK_DIR)
|
||||
json_path = cleaner.clean()
|
||||
|
||||
# Load structure
|
||||
with open(json_path, 'r') as f:
|
||||
structure = BookStructure.model_validate_json(f.read())
|
||||
|
||||
# Build
|
||||
logger.info("Step 2: Building EPUB with TOC preservation...")
|
||||
builder = BilingualBuilder(WORK_DIR, original_epub_path=INPUT_EPUB)
|
||||
builder.build(structure, OUTPUT_EPUB)
|
||||
|
||||
logger.info(f"Step 3: EPUB generated at {OUTPUT_EPUB}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting verification script...")
|
||||
try:
|
||||
verify_fix()
|
||||
print("Verification script finished.")
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print(f"CRITICAL ERROR: {e}")
|
||||
@@ -0,0 +1,96 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.data_model import BookStructure, ManifestEntry
|
||||
from src.format_restorer import FormatRestorer
|
||||
from src.utils import setup_logger
|
||||
|
||||
logger = setup_logger("backfill_engine")
|
||||
|
||||
class BackfillEngine:
|
||||
"""
|
||||
Applies translations back to the BookStructure.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.restorer = FormatRestorer()
|
||||
|
||||
def backfill(self, structure: BookStructure, manifest_entries: List[ManifestEntry], mode: str = "bilingual") -> BookStructure:
|
||||
"""
|
||||
Modifies the BookStructure in-place with translations.
|
||||
|
||||
Args:
|
||||
structure: The BookStructure (from book_structure.json).
|
||||
manifest_entries: List of translations.
|
||||
mode: 'bilingual' or 'target_only'.
|
||||
"""
|
||||
logger.info(f"Backfilling with mode: {mode}")
|
||||
|
||||
# Index manifest by file and element ID for faster lookup
|
||||
# Map: file_path -> element_id -> ManifestEntry
|
||||
manifest_map: Dict[str, Dict[str, ManifestEntry]] = {}
|
||||
for entry in manifest_entries:
|
||||
if not entry.translated_text:
|
||||
continue # Skip untranslated entries
|
||||
|
||||
if entry.file_path not in manifest_map:
|
||||
manifest_map[entry.file_path] = {}
|
||||
manifest_map[entry.file_path][entry.element_id] = entry
|
||||
|
||||
# Iterate resources in structure
|
||||
for item_id, resource in structure.resources.items():
|
||||
if resource.media_type != "application/xhtml+xml" or resource.href not in manifest_map:
|
||||
continue
|
||||
|
||||
file_entries = manifest_map[resource.href]
|
||||
if not file_entries:
|
||||
continue
|
||||
|
||||
logger.debug(f"Processing {resource.href} with {len(file_entries)} translations")
|
||||
|
||||
soup = BeautifulSoup(resource.content, 'html.parser')
|
||||
modified = False
|
||||
|
||||
for element_id, entry in file_entries.items():
|
||||
element = soup.find(id=element_id)
|
||||
if not element:
|
||||
logger.warning(f"Element {element_id} not found in {resource.href}")
|
||||
continue
|
||||
|
||||
# Restore formatting
|
||||
restored_html, _ = self.restorer.restore(entry.translated_text, entry.placeholders)
|
||||
|
||||
# Create translated tag
|
||||
new_tag = soup.new_tag(element.name)
|
||||
# Parse restored HTML to get content nodes
|
||||
inner_soup = BeautifulSoup(restored_html, 'html.parser')
|
||||
if inner_soup.body:
|
||||
for child in list(inner_soup.body.children):
|
||||
new_tag.append(child)
|
||||
else:
|
||||
for child in list(inner_soup.children):
|
||||
new_tag.append(child)
|
||||
|
||||
# Copy classes and add 'translation'
|
||||
classes = element.get('class', [])
|
||||
if isinstance(classes, str):
|
||||
classes = classes.split()
|
||||
new_tag['class'] = classes + ['translation']
|
||||
|
||||
# Copy style
|
||||
style = element.get('style')
|
||||
if style:
|
||||
new_tag['style'] = style
|
||||
|
||||
if mode == "bilingual":
|
||||
element.insert_after(new_tag)
|
||||
else:
|
||||
element.replace_with(new_tag)
|
||||
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
resource.content = str(soup)
|
||||
|
||||
return structure
|
||||
@@ -0,0 +1,252 @@
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from ebooklib import epub
|
||||
from src.data_model import BookStructure
|
||||
from src.utils import setup_logger, ensure_directory
|
||||
|
||||
logger = setup_logger("bilingual_builder")
|
||||
|
||||
class BilingualBuilder:
|
||||
"""
|
||||
Assembles the final EPUB from BookStructure.
|
||||
|
||||
Preserves the original TOC structure by reading it from the original EPUB.
|
||||
"""
|
||||
|
||||
def __init__(self, work_dir: Path, original_epub_path: Path = None):
|
||||
self.work_dir = work_dir
|
||||
self.assets_dir = work_dir / "assets"
|
||||
self.original_epub_path = original_epub_path
|
||||
self._original_book = None
|
||||
|
||||
def _load_original_book(self):
|
||||
"""Lazy load original book for TOC extraction."""
|
||||
if self._original_book is None and self.original_epub_path and self.original_epub_path.exists():
|
||||
try:
|
||||
self._original_book = epub.read_epub(str(self.original_epub_path))
|
||||
logger.debug(f"Loaded original EPUB for TOC: {self.original_epub_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load original EPUB: {e}")
|
||||
return self._original_book
|
||||
|
||||
def _sanitize_toc(self, toc):
|
||||
"""
|
||||
Ensure all TOC nodes have IDs (for ebooklib compatibility).
|
||||
From v0.08 bilingual_builder.py
|
||||
"""
|
||||
result = []
|
||||
for item in toc:
|
||||
if isinstance(item, (epub.Link, epub.Section)):
|
||||
if not getattr(item, 'uid', None):
|
||||
item.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
|
||||
result.append(item)
|
||||
elif isinstance(item, tuple) and len(item) == 2:
|
||||
# Handle (Section, [children]) structure
|
||||
section, children = item
|
||||
if isinstance(section, (epub.Link, epub.Section)):
|
||||
if not getattr(section, 'uid', None):
|
||||
section.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
|
||||
sanitized_children = self._sanitize_toc(children)
|
||||
result.append((section, sanitized_children))
|
||||
else:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def _validate_and_fix_toc(self, toc, book_items):
|
||||
"""
|
||||
Recursively validate and fix TOC links.
|
||||
Removes nodes with broken links that cannot be fixed.
|
||||
"""
|
||||
fixed_toc = []
|
||||
for item in toc:
|
||||
if isinstance(item, (epub.Link, epub.Section)):
|
||||
# Check href
|
||||
href = getattr(item, 'href', '')
|
||||
if href:
|
||||
# Remove anchor for check
|
||||
clean_href = href.split('#')[0]
|
||||
# Check if item exists in book (by file_name)
|
||||
found = False
|
||||
for existing_item in book_items.values():
|
||||
if existing_item.file_name == clean_href:
|
||||
found = True
|
||||
break
|
||||
if clean_href.endswith(existing_item.file_name) or existing_item.file_name.endswith(clean_href):
|
||||
item.href = existing_item.file_name
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
if 'c0.xhtml' in clean_href:
|
||||
for existing_item in book_items.values():
|
||||
if 'titlepage' in existing_item.file_name or 'cover' in existing_item.file_name.lower():
|
||||
if existing_item.media_type == "application/xhtml+xml":
|
||||
logger.info(f"Fixed TOC link: {href} -> {existing_item.file_name}")
|
||||
item.href = existing_item.file_name
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
logger.warning(f"Removing broken TOC link: {href}")
|
||||
continue
|
||||
|
||||
if isinstance(item, tuple) and len(item) == 2:
|
||||
section, children = item
|
||||
fixed_children = self._validate_and_fix_toc(children, book_items)
|
||||
fixed_toc.append((section, fixed_children))
|
||||
else:
|
||||
fixed_toc.append(item)
|
||||
|
||||
return fixed_toc
|
||||
|
||||
def build(self, structure: BookStructure, output_path: Path) -> Path:
|
||||
"""
|
||||
Builds the EPUB file.
|
||||
Returns the path to the generated EPUB.
|
||||
"""
|
||||
logger.info(f"Building final EPUB: {output_path}")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
book = epub.EpubBook()
|
||||
|
||||
# 1. Metadata
|
||||
book.set_identifier(structure.metadata.identifier or f"uuid-{uuid.uuid4().hex[:12]}")
|
||||
book.set_title(structure.metadata.title)
|
||||
book.set_language(structure.metadata.language)
|
||||
book.add_author(structure.metadata.author)
|
||||
|
||||
# 2. Copy TOC from original EPUB if available
|
||||
original_book = self._load_original_book()
|
||||
if original_book and hasattr(original_book, 'toc') and original_book.toc:
|
||||
book.toc = self._sanitize_toc(original_book.toc)
|
||||
logger.info("Copied TOC structure from original EPUB")
|
||||
|
||||
|
||||
|
||||
# 3. Add Resources - First pass: Collect CSS items
|
||||
items_map = {} # id -> epub_item
|
||||
css_items = [] # List of CSS EpubItem for linking
|
||||
html_items = [] # List of (item_id, EpubHtml) tuples
|
||||
|
||||
for item_id, resource in structure.resources.items():
|
||||
# Skip NCX - we'll handle it separately
|
||||
if resource.media_type == "application/x-dtbncx+xml":
|
||||
continue
|
||||
|
||||
if resource.media_type == "application/xhtml+xml":
|
||||
# HTML Item - create but don't add yet (need to add CSS links)
|
||||
item = epub.EpubHtml(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=resource.content.encode('utf-8')
|
||||
)
|
||||
html_items.append((item_id, item))
|
||||
elif resource.file_path:
|
||||
# Binary/Asset Item
|
||||
asset_full_path = self.work_dir / resource.file_path
|
||||
|
||||
if not asset_full_path.exists():
|
||||
logger.warning(f"Asset missing: {asset_full_path}")
|
||||
continue
|
||||
|
||||
with open(asset_full_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
if "image" in resource.media_type:
|
||||
item = epub.EpubImage(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=content
|
||||
)
|
||||
else:
|
||||
item = epub.EpubItem(
|
||||
uid=item_id,
|
||||
file_name=resource.href,
|
||||
media_type=resource.media_type,
|
||||
content=content
|
||||
)
|
||||
# Track CSS items
|
||||
if resource.media_type == "text/css":
|
||||
css_items.append(item)
|
||||
|
||||
book.add_item(item)
|
||||
items_map[item_id] = item
|
||||
else:
|
||||
logger.warning(f"Skipping resource {item_id}: No content or file path.")
|
||||
continue
|
||||
|
||||
# 4. Add HTML items with CSS links
|
||||
for item_id, item in html_items:
|
||||
html_dir = Path(item.file_name).parent
|
||||
for css_item in css_items:
|
||||
css_path = Path(css_item.file_name)
|
||||
# Calculate relative path from HTML directory to CSS file
|
||||
try:
|
||||
relative_css_path = Path(css_path).relative_to(html_dir)
|
||||
except ValueError:
|
||||
# Not a subpath, calculate full relative
|
||||
# Go up from html_dir, then down to css_path
|
||||
up_count = len(html_dir.parts)
|
||||
relative_css_path = Path("/".join([".."] * up_count)) / css_path
|
||||
|
||||
item.add_link(href=str(relative_css_path), rel='stylesheet', type='text/css')
|
||||
book.add_item(item)
|
||||
items_map[item_id] = item
|
||||
|
||||
# 5. Copy missing items from original EPUB (cover, etc.)
|
||||
# This ensures TOC links don't break
|
||||
if original_book:
|
||||
added_hrefs = {item.file_name for item in items_map.values() if hasattr(item, 'file_name')}
|
||||
|
||||
for orig_item in original_book.get_items():
|
||||
orig_name = orig_item.get_name()
|
||||
if orig_name not in added_hrefs:
|
||||
# Skip NCX and NAV - we generate these
|
||||
if 'ncx' in orig_name.lower() or orig_name.endswith('nav.xhtml'):
|
||||
continue
|
||||
|
||||
# Copy the item directly
|
||||
try:
|
||||
book.add_item(orig_item)
|
||||
items_map[orig_item.id] = orig_item
|
||||
logger.debug(f"Copied missing item from original: {orig_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to copy item {orig_name}: {e}")
|
||||
|
||||
# 6. Spine
|
||||
book.spine = []
|
||||
for item_id in structure.spine:
|
||||
if item_id in items_map:
|
||||
book.spine.append(items_map[item_id])
|
||||
else:
|
||||
logger.warning(f"Spine item {item_id} not found in resources.")
|
||||
|
||||
# Add missing spine items from original
|
||||
if original_book:
|
||||
for spine_id, _ in original_book.spine:
|
||||
if spine_id not in [i.id for i in book.spine]:
|
||||
orig_item = original_book.get_item_with_id(spine_id)
|
||||
if orig_item and orig_item.id in items_map:
|
||||
book.spine.append(items_map[orig_item.id])
|
||||
|
||||
# Validate and fix TOC
|
||||
if original_book and hasattr(book, 'toc') and book.toc:
|
||||
try:
|
||||
book.toc = self._validate_and_fix_toc(book.toc, items_map)
|
||||
logger.info("Validated and fixed TOC links")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate TOC: {e}")
|
||||
|
||||
# 7. Navigation - NCX and Nav
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
# 7. Write
|
||||
epub.write_epub(str(output_path), book)
|
||||
logger.info(f"EPUB created successfully at {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
import random
|
||||
from typing import Dict, List
|
||||
from loguru import logger
|
||||
from src.llm_client import LLMClient
|
||||
from src.manifest_manager import ManifestManager
|
||||
from src.data_model import BookProfile
|
||||
|
||||
class BookProfiler:
|
||||
def __init__(self, llm_client: LLMClient):
|
||||
self.llm_client = llm_client
|
||||
|
||||
def extract_sample_text(self, entries: List[object], char_limit: int = 3000) -> str:
|
||||
"""Extract sample text from manifest entries."""
|
||||
if not entries: return ""
|
||||
|
||||
# Simple sampling strategy: First few + random middle
|
||||
intro_text = []
|
||||
for entry in entries[:50]:
|
||||
if len(entry.original_text) > 50:
|
||||
intro_text.append(entry.original_text)
|
||||
|
||||
body_text = []
|
||||
candidates = [e for e in entries[50:] if len(e.original_text) > 80]
|
||||
if candidates:
|
||||
samples = random.sample(candidates, min(5, len(candidates)))
|
||||
body_text = [e.original_text for e in samples]
|
||||
|
||||
full_text = "\n\n".join(intro_text[:5] + body_text)
|
||||
return full_text[:char_limit]
|
||||
|
||||
async def analyze(self, entries: List[object]) -> BookProfile:
|
||||
"""Generate Book Profile."""
|
||||
sample = self.extract_sample_text(entries)
|
||||
if not sample:
|
||||
return BookProfile(title="Unknown", author="Unknown")
|
||||
|
||||
logger.info("Generating Book Profile from sample text...")
|
||||
|
||||
system_prompt = "You are a senior publishing editor. Analyze the text and output JSON."
|
||||
user_prompt = f"""
|
||||
Please analyze the following book excerpt.
|
||||
Output JSON format:
|
||||
{{
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"genre": "Genre",
|
||||
"style": "Style description",
|
||||
"keywords": ["keyword1", "keyword2"],
|
||||
"style_guide": "Specific instruction for translator"
|
||||
}}
|
||||
|
||||
Excerpt:
|
||||
{sample}
|
||||
"""
|
||||
try:
|
||||
response = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
|
||||
json_str = response.strip()
|
||||
# Basic cleanup
|
||||
if "```json" in json_str:
|
||||
json_str = json_str.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in json_str:
|
||||
json_str = json_str.split("```")[1].split("```")[0].strip()
|
||||
|
||||
data = json.loads(json_str)
|
||||
|
||||
return BookProfile(
|
||||
title=data.get("title", "Unknown"),
|
||||
author=data.get("author", "Unknown"),
|
||||
genre=data.get("genre", "General"),
|
||||
keywords=data.get("keywords", []),
|
||||
style_guide=data.get("style_guide", "")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Profile generation failed: {e}")
|
||||
return BookProfile(title="Unknown", author="Unknown")
|
||||
@@ -0,0 +1,51 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class ManifestEntry(BaseModel):
|
||||
"""Represents a single translatable unit."""
|
||||
entry_id: str = Field(..., description="Global unique ID (e.g. file.html#paragraph_id)")
|
||||
file_path: str = Field(..., description="Internal path in EPUB")
|
||||
element_id: str = Field(..., description="HTML ID (e.g. uuid-1234)")
|
||||
original_text: str
|
||||
placeholders: Dict[str, str] = Field(default_factory=dict)
|
||||
translated_text: Optional[str] = None
|
||||
context: Optional[str] = None
|
||||
|
||||
class BookMetaData(BaseModel):
|
||||
title: str = "Unknown Title"
|
||||
author: str = "Unknown Author"
|
||||
language: str = "en"
|
||||
identifier: str = ""
|
||||
|
||||
class ResourceItem(BaseModel):
|
||||
href: str
|
||||
media_type: str
|
||||
content: Optional[str] = None # For text/html
|
||||
file_path: Optional[str] = None # For binary/assets (relative to assets dir)
|
||||
properties: Optional[str] = None
|
||||
|
||||
class BookStructure(BaseModel):
|
||||
metadata: BookMetaData
|
||||
spine: List[str] = Field(default_factory=list, description="Ordered list of item IDs in spine")
|
||||
resources: Dict[str, ResourceItem] = Field(default_factory=dict, description="Map of item_id to ResourceItem")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "BookStructure":
|
||||
"""Load BookStructure from JSON file."""
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return cls.model_validate_json(f.read())
|
||||
|
||||
def save(self, path: Path):
|
||||
"""Save BookStructure to JSON file."""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(self.model_dump_json(indent=2))
|
||||
|
||||
class BookProfile(BaseModel):
|
||||
"""Represents the profile of the book."""
|
||||
title: str
|
||||
author: str
|
||||
genre: str = "General"
|
||||
keywords: List[str] = Field(default_factory=list)
|
||||
style_guide: str = ""
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import shutil
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Set
|
||||
|
||||
import ebooklib
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from ebooklib import epub
|
||||
|
||||
from src.data_model import BookStructure, BookMetaData, ResourceItem
|
||||
from src.exceptions import CleaningError
|
||||
from src.utils import setup_logger, ensure_directory
|
||||
|
||||
logger = setup_logger("epub_cleaner")
|
||||
|
||||
class EpubCleaner:
|
||||
def __init__(self, input_path: Path, work_dir: Path):
|
||||
self.input_path = input_path
|
||||
self.work_dir = work_dir
|
||||
self.assets_dir = work_dir / "assets"
|
||||
self.json_path = work_dir / "book_structure.json"
|
||||
|
||||
def clean(self) -> Path:
|
||||
"""
|
||||
Cleans the input EPUB and generates book_structure.json.
|
||||
Returns the path to the JSON file.
|
||||
"""
|
||||
logger.info(f"Starting cleanup for {self.input_path}")
|
||||
ensure_directory(self.work_dir)
|
||||
ensure_directory(self.assets_dir)
|
||||
|
||||
try:
|
||||
book = epub.read_epub(self.input_path)
|
||||
|
||||
# 1. Extract Metadata
|
||||
metadata = self._extract_metadata(book)
|
||||
|
||||
# 2. Process Resources
|
||||
resources = {}
|
||||
# Use zipfile for binary extraction to avoid ebooklib's memory overhead/decoding issues
|
||||
with zipfile.ZipFile(self.input_path, 'r') as zf:
|
||||
# Map ebooklib items to zip entries isn't straightforward directly via name
|
||||
# So we iterate ebooklib items and assume standard structure or handle content bytes
|
||||
|
||||
for item in book.get_items():
|
||||
item_id = item.get_id()
|
||||
file_name = item.get_name()
|
||||
media_type = item.get_type() # ebooklib constant
|
||||
|
||||
if media_type == ebooklib.ITEM_DOCUMENT:
|
||||
# Clean HTML
|
||||
content_str = item.get_content().decode('utf-8')
|
||||
cleaned_content = self._clean_html(content_str, file_name)
|
||||
|
||||
resources[item_id] = ResourceItem(
|
||||
href=file_name,
|
||||
media_type="application/xhtml+xml",
|
||||
content=cleaned_content
|
||||
)
|
||||
elif media_type in (ebooklib.ITEM_IMAGE, ebooklib.ITEM_STYLE, ebooklib.ITEM_FONT, ebooklib.ITEM_COVER):
|
||||
# Save asset
|
||||
# Preserve directory structure to avoid collisions
|
||||
asset_path = self.assets_dir / file_name
|
||||
ensure_directory(asset_path.parent)
|
||||
|
||||
# Ebooklib might change filenames, safer to use item.get_content()
|
||||
with open(asset_path, "wb") as f:
|
||||
f.write(item.get_content())
|
||||
|
||||
resources[item_id] = ResourceItem(
|
||||
href=file_name,
|
||||
media_type=self._get_media_type_str(item),
|
||||
file_path=str(asset_path.relative_to(self.work_dir))
|
||||
)
|
||||
elif media_type == ebooklib.ITEM_NAVIGATION:
|
||||
# NCX or NAV document - preserve for TOC
|
||||
content_bytes = item.get_content()
|
||||
asset_path = self.assets_dir / file_name
|
||||
ensure_directory(asset_path.parent)
|
||||
|
||||
with open(asset_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
# Determine media type
|
||||
if file_name.endswith('.ncx'):
|
||||
mt = "application/x-dtbncx+xml"
|
||||
else:
|
||||
mt = "application/xhtml+xml"
|
||||
|
||||
resources[item_id] = ResourceItem(
|
||||
href=file_name,
|
||||
media_type=mt,
|
||||
file_path=str(asset_path.relative_to(self.work_dir))
|
||||
)
|
||||
logger.debug(f"Preserved navigation: {file_name}")
|
||||
else:
|
||||
# Skip other items (scripts, etc.)
|
||||
pass
|
||||
|
||||
# 3. Extract Spine
|
||||
spine_ids = [item[0] for item in book.spine]
|
||||
|
||||
# 4. Construct Structure
|
||||
structure = BookStructure(
|
||||
metadata=metadata,
|
||||
spine=spine_ids,
|
||||
resources=resources
|
||||
)
|
||||
|
||||
# 5. Serialize
|
||||
with open(self.json_path, "w", encoding="utf-8") as f:
|
||||
f.write(structure.model_dump_json(indent=2))
|
||||
|
||||
logger.info(f"Cleanup finished. Structure saved to {self.json_path}")
|
||||
return self.json_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cleaning failed: {e}")
|
||||
raise CleaningError(f"Failed to clean EPUB: {e}") from e
|
||||
|
||||
def _extract_metadata(self, book: epub.EpubBook) -> BookMetaData:
|
||||
title = book.get_metadata('DC', 'title')[0][0] if book.get_metadata('DC', 'title') else "Unknown"
|
||||
author = book.get_metadata('DC', 'creator')[0][0] if book.get_metadata('DC', 'creator') else "Unknown"
|
||||
lang = book.get_metadata('DC', 'language')[0][0] if book.get_metadata('DC', 'language') else "en"
|
||||
ident = book.get_metadata('DC', 'identifier')[0][0] if book.get_metadata('DC', 'identifier') else ""
|
||||
|
||||
return BookMetaData(
|
||||
title=str(title),
|
||||
author=str(author),
|
||||
language=str(lang),
|
||||
identifier=str(ident)
|
||||
)
|
||||
|
||||
def _get_media_type_str(self, item) -> str:
|
||||
# Helper to map ebooklib type to mime string if needed
|
||||
# ebooklib doesn't expose easy MIME string for all types directly on item object sometimes
|
||||
if hasattr(item, 'media_type'):
|
||||
return item.media_type
|
||||
return "application/octet-stream"
|
||||
|
||||
def _clean_html(self, content: str, filename: str) -> str:
|
||||
soup = BeautifulSoup(content, 'html.parser')
|
||||
|
||||
# 1. Provide IDs for structural/translatable elements
|
||||
self._ensure_element_ids(soup)
|
||||
|
||||
# 2. Flatten divs (Disable to prevent style loss)
|
||||
# self._flatten_divs(soup)
|
||||
|
||||
return str(soup)
|
||||
|
||||
def _ensure_element_ids(self, soup: BeautifulSoup):
|
||||
"""
|
||||
Injects UUIDs into p, h1-h6, li tags if they don't have an ID.
|
||||
This provides the anchor for translation backfilling.
|
||||
"""
|
||||
targets = soup.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li'])
|
||||
for tag in targets:
|
||||
if not tag.has_attr('id'):
|
||||
tag['id'] = f"uuid-{uuid.uuid4()}"
|
||||
|
||||
def _flatten_divs(self, soup: BeautifulSoup):
|
||||
"""
|
||||
Converts generic divs containing only inline text/styles to p tags.
|
||||
Recursive strategies can be complex, sticking to simple heuristic from archive.
|
||||
"""
|
||||
inline_tags = {'span', 'em', 'strong', 'i', 'b', 'u', 'a', 'br', 'sub', 'sup', 'small'}
|
||||
|
||||
for div in list(soup.find_all('div')):
|
||||
# If div has no block children, convert to p
|
||||
has_block = any(
|
||||
isinstance(c, Tag) and c.name not in inline_tags
|
||||
for c in div.children
|
||||
)
|
||||
|
||||
if not has_block:
|
||||
div.name = 'p'
|
||||
@@ -0,0 +1,18 @@
|
||||
class EpubTranslatorError(Exception):
|
||||
"""Base exception for Epub Translator."""
|
||||
pass
|
||||
|
||||
class CleaningError(EpubTranslatorError):
|
||||
pass
|
||||
|
||||
class ExtractionError(EpubTranslatorError):
|
||||
pass
|
||||
|
||||
class TranslationError(EpubTranslatorError):
|
||||
pass
|
||||
|
||||
class RestorationError(EpubTranslatorError):
|
||||
pass
|
||||
|
||||
class BuildError(EpubTranslatorError):
|
||||
pass
|
||||
@@ -0,0 +1,132 @@
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional
|
||||
from bs4 import BeautifulSoup
|
||||
from loguru import logger
|
||||
|
||||
from src.data_model import BookStructure, ManifestEntry, BookProfile
|
||||
from src.format_extractor import FormatExtractor
|
||||
from src.utils import setup_logger
|
||||
|
||||
logger = setup_logger("fine_grained_extractor")
|
||||
|
||||
class FineGrainedExtractor:
|
||||
"""
|
||||
Extracts translatable text segments from BookStructure.
|
||||
Uses FormatExtractor for detailed content analysis.
|
||||
"""
|
||||
|
||||
SKIP_TRANSLATION_PATTERNS = [
|
||||
r'index\.x?html',
|
||||
r'bibliography\.x?html',
|
||||
r'endnotes?\.x?html',
|
||||
r'footnotes?\.x?html',
|
||||
r'copyright\.x?html',
|
||||
]
|
||||
|
||||
TOC_PATTERNS = [
|
||||
r'nav\.x?html',
|
||||
r'toc\.x?html',
|
||||
]
|
||||
|
||||
def __init__(self, translate_toc: bool = False):
|
||||
self.translate_toc = translate_toc
|
||||
self.format_extractor = FormatExtractor()
|
||||
|
||||
def extract(self, structure: BookStructure, profile: Optional[BookProfile] = None) -> List[ManifestEntry]:
|
||||
"""
|
||||
Extracts translatable segments from the BookStructure.
|
||||
Iteration follows the spine order.
|
||||
"""
|
||||
logger.info("Starting extraction from BookStructure...")
|
||||
manifest_entries = []
|
||||
|
||||
# Iterate over spine to maintain order
|
||||
for item_id in structure.spine:
|
||||
if item_id not in structure.resources:
|
||||
logger.warning(f"Item ID {item_id} in spine but not in resources.")
|
||||
continue
|
||||
|
||||
resource = structure.resources[item_id]
|
||||
|
||||
# Only process HTML/XHTML
|
||||
if resource.media_type != "application/xhtml+xml" or not resource.content:
|
||||
continue
|
||||
|
||||
file_path = resource.href
|
||||
doc_type = self._classify_document(file_path)
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(resource.content, 'html.parser')
|
||||
target_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li']
|
||||
|
||||
for element in soup.find_all(target_tags):
|
||||
# Ensure element has ID (should have been done by Cleaner)
|
||||
element_id = element.get('id')
|
||||
if not element_id:
|
||||
logger.warning(f"Element in {file_path} missing ID, skipping: {element.name}")
|
||||
continue
|
||||
|
||||
# Check translation eligibility
|
||||
raw_text = element.get_text(separator=' ', strip=True)
|
||||
if not raw_text.strip():
|
||||
continue
|
||||
|
||||
is_decorative = self._is_decorative(raw_text)
|
||||
should_translate = self._should_translate(doc_type, is_decorative, raw_text)
|
||||
|
||||
if should_translate:
|
||||
# Extract detailed format
|
||||
outer_html = str(element)
|
||||
clean_text, text_with_ph, ph_map, p_type, _ = self.format_extractor.extract(outer_html)
|
||||
|
||||
if clean_text.strip() and text_with_ph.strip():
|
||||
entry = ManifestEntry(
|
||||
entry_id=f"{file_path}#{element_id}",
|
||||
file_path=file_path,
|
||||
element_id=element_id,
|
||||
original_text=text_with_ph,
|
||||
placeholders=ph_map,
|
||||
context=p_type
|
||||
)
|
||||
manifest_entries.append(entry)
|
||||
|
||||
logger.info(f"Extracted {len(manifest_entries)} entries in total.")
|
||||
return manifest_entries
|
||||
|
||||
def _classify_document(self, file_name: str) -> str:
|
||||
if not file_name: return 'core'
|
||||
fname = file_name.lower()
|
||||
if any(re.search(p, fname) for p in self.SKIP_TRANSLATION_PATTERNS): return 'skip'
|
||||
if any(re.search(p, fname) for p in self.TOC_PATTERNS): return 'toc'
|
||||
return 'core'
|
||||
|
||||
def _should_translate(self, doc_type: str, is_decorative: bool, text: str = "") -> bool:
|
||||
if is_decorative: return False
|
||||
if self._is_roman_numeral(text): return False
|
||||
if doc_type == 'core': return True
|
||||
if doc_type == 'toc': return self.translate_toc
|
||||
return False
|
||||
|
||||
def _is_roman_numeral(self, text: str) -> bool:
|
||||
text = text.strip().upper()
|
||||
if not text: return False
|
||||
pattern = re.compile(r"^M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$")
|
||||
return bool(pattern.match(text))
|
||||
|
||||
def _is_decorative(self, text: str) -> bool:
|
||||
s = text.strip()
|
||||
if not s: return False
|
||||
if not any(c.isalnum() for c in s): return True
|
||||
if len(s) > 20: return False
|
||||
|
||||
patterns = [
|
||||
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾]+$',
|
||||
r'^[*\-_~•◆◇■□▪▫●○◉◎⦿⦾\s]+$',
|
||||
]
|
||||
for p in patterns:
|
||||
if re.match(p, s): return True
|
||||
|
||||
unique = set(s.replace(' ', ''))
|
||||
if len(unique) <= 3 and (unique & set('*-_~•◆◇■□▪▫●○◉◎⦿⦾')):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,372 @@
|
||||
import re
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from typing import Tuple, Dict, List
|
||||
|
||||
class HeadingDetector:
|
||||
"""Detects heading types and paragraph roles."""
|
||||
|
||||
CHAPTER_PATTERNS = [
|
||||
r'^(chapter|chap\.?|part)\s+([0-9]+|[ivxlc]+|[a-z])',
|
||||
r'^(第\s*[0-9一二三四五六七八九十百]+\s*[章节部篇])',
|
||||
r'^(\d+|[IVXLC]+|[A-Z])\.$'
|
||||
]
|
||||
|
||||
EPIGRAPH_CLASSES = {
|
||||
'epigraph', 'quote', 'blockquote', 'motto',
|
||||
'dedication', 'verse', 'poetry', 'poem'
|
||||
}
|
||||
|
||||
def detect(self, element: Tag, text: str) -> str:
|
||||
if self._is_epigraph(element):
|
||||
return "epigraph"
|
||||
tag_name = element.name.lower()
|
||||
if tag_name in ['h1', 'h2']:
|
||||
return "chapter" if self._matches_chapter_pattern(text) else "section"
|
||||
if tag_name == 'h3':
|
||||
return "section"
|
||||
if tag_name in ['h4', 'h5', 'h6']:
|
||||
return "subsection"
|
||||
if self._is_pseudo_heading(element, text):
|
||||
return "subsection"
|
||||
return "body"
|
||||
|
||||
def _is_epigraph(self, element: Tag) -> bool:
|
||||
if element.name == 'blockquote':
|
||||
return True
|
||||
current = element
|
||||
for _ in range(3):
|
||||
if not current: break
|
||||
classes = current.get('class', [])
|
||||
if isinstance(classes, list):
|
||||
classes = ' '.join(classes)
|
||||
if any(k in classes.lower() for k in self.EPIGRAPH_CLASSES):
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
def _matches_chapter_pattern(self, text: str) -> bool:
|
||||
text = text.strip().lower()
|
||||
for pattern in self.CHAPTER_PATTERNS:
|
||||
if re.match(pattern, text, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_pseudo_heading(self, element: Tag, text: str) -> bool:
|
||||
if element.name != 'p':
|
||||
return False
|
||||
text = text.strip()
|
||||
if not text or len(text) > 80:
|
||||
return False
|
||||
children = list(element.children)
|
||||
if len(children) == 1 and isinstance(children[0], Tag):
|
||||
if children[0].name in ['strong', 'b']:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FormatExtractor:
|
||||
"""
|
||||
HTML Format Extractor (Ported from v0.09 v3)
|
||||
Handles inline styles, formulas, and drop caps.
|
||||
"""
|
||||
|
||||
FORMULA_CHARS = re.compile(
|
||||
r'^[\d\s\+\-\×\÷\=\(\)\[\]\{\}\<\>\^\*\/\.\,\;\:\'\"\`\~\@\#\$\%\&\|\\'
|
||||
r'αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ'
|
||||
r'a-zA-Z]+$'
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.detector = HeadingDetector()
|
||||
|
||||
def extract(self, element_html: str) -> Tuple[str, str, Dict[str, str], str, List[str]]:
|
||||
"""
|
||||
Extracts format information.
|
||||
|
||||
Returns:
|
||||
clean_text: Pure text
|
||||
text_with_placeholders: Text with inline placeholders
|
||||
placeholder_map: Map of placeholders
|
||||
paragraph_type: Detected type
|
||||
endnote_anchors: List of detected endnote IDs
|
||||
"""
|
||||
soup = BeautifulSoup(element_html, 'html.parser')
|
||||
root = list(soup.children)[0] if list(soup.children) else soup
|
||||
|
||||
clean_text = root.get_text().strip()
|
||||
clean_text = re.sub(r'\s+', ' ', clean_text)
|
||||
p_type = self.detector.detect(root, clean_text) if isinstance(root, Tag) else "body"
|
||||
|
||||
inner_html = root.decode_contents() if isinstance(root, Tag) else str(root)
|
||||
|
||||
text_with_ph, local_map = self._smart_extract_v3(inner_html)
|
||||
|
||||
if text_with_ph:
|
||||
text_with_ph = re.sub(r'\s+', ' ', text_with_ph).strip()
|
||||
|
||||
# Verify integrity
|
||||
stripped_text = self._strip_placeholders(text_with_ph)
|
||||
stripped_text = re.sub(r'\s+', ' ', stripped_text).strip()
|
||||
|
||||
if not self._verify_content_integrity(clean_text, stripped_text):
|
||||
# Fallback
|
||||
text_with_ph, local_map = self._fallback_extract(inner_html, clean_text)
|
||||
|
||||
endnote_anchors = []
|
||||
for pid, html in local_map.items():
|
||||
if pid.startswith("_"):
|
||||
continue
|
||||
if re.match(r'<(span|a)\s+id="[a-zA-Z][a-zA-Z0-9]{2,5}"\s*>\s*</\1>', html):
|
||||
endnote_anchors.append(pid)
|
||||
|
||||
return clean_text, text_with_ph, local_map, p_type, endnote_anchors
|
||||
|
||||
def _strip_placeholders(self, text: str) -> str:
|
||||
return re.sub(r'φ/?[0-9]+φ', '', text)
|
||||
|
||||
def _verify_content_integrity(self, clean_text: str, stripped_text: str) -> bool:
|
||||
def normalize(s):
|
||||
s = re.sub(r'\s+', '', s)
|
||||
s = s.lower()
|
||||
return s
|
||||
|
||||
norm_clean = normalize(clean_text)
|
||||
norm_stripped = normalize(stripped_text)
|
||||
|
||||
if norm_clean == norm_stripped:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _fallback_extract(self, inner_html: str, clean_text: str) -> Tuple[str, Dict[str, str]]:
|
||||
return clean_text, {"_prefix": "", "_suffix": ""}
|
||||
|
||||
def _smart_extract_v3(self, inner_html: str) -> Tuple[str, Dict[str, str]]:
|
||||
parts = re.split(r'(<[^>]+>)', inner_html)
|
||||
parts = [p for p in parts if p]
|
||||
|
||||
if not parts:
|
||||
return "", {"_prefix": "", "_suffix": ""}
|
||||
|
||||
part_types = []
|
||||
for part in parts:
|
||||
if part.startswith('<'):
|
||||
part_types.append('tag')
|
||||
elif not part.strip():
|
||||
part_types.append('whitespace')
|
||||
elif self._is_translatable_text(part):
|
||||
part_types.append('translatable')
|
||||
else:
|
||||
part_types.append('formula')
|
||||
|
||||
first_trans_idx = None
|
||||
last_trans_idx = None
|
||||
for i, t in enumerate(part_types):
|
||||
if t == 'translatable':
|
||||
if first_trans_idx is None:
|
||||
first_trans_idx = i
|
||||
last_trans_idx = i
|
||||
|
||||
if first_trans_idx is None:
|
||||
return "", {"_prefix": inner_html, "_suffix": ""}
|
||||
|
||||
# Prefix Separation
|
||||
safe_prefix_end = 0
|
||||
for i in range(first_trans_idx):
|
||||
if part_types[i] == 'tag':
|
||||
tag = parts[i]
|
||||
is_self_closing = tag.endswith('/>') or re.match(r'<(br|hr|img|meta|link)\b', tag, re.I)
|
||||
is_closing = tag.startswith('</')
|
||||
if is_self_closing or is_closing:
|
||||
safe_prefix_end = i + 1
|
||||
else:
|
||||
break
|
||||
elif part_types[i] == 'whitespace':
|
||||
safe_prefix_end = i + 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Suffix Separation
|
||||
safe_suffix_start = len(parts)
|
||||
for i in range(len(parts) - 1, last_trans_idx, -1):
|
||||
if part_types[i] == 'tag':
|
||||
tag = parts[i]
|
||||
is_closing = tag.startswith('</')
|
||||
is_self_closing = tag.endswith('/>') or re.match(r'<(br|hr|img|meta|link)\b', tag, re.I)
|
||||
if is_closing or is_self_closing:
|
||||
safe_suffix_start = i
|
||||
else:
|
||||
break
|
||||
elif part_types[i] == 'whitespace':
|
||||
safe_suffix_start = i
|
||||
else:
|
||||
break
|
||||
|
||||
prefix_parts = parts[:safe_prefix_end]
|
||||
middle_parts = parts[safe_prefix_end:safe_suffix_start]
|
||||
middle_types = part_types[safe_prefix_end:safe_suffix_start]
|
||||
suffix_parts = parts[safe_suffix_start:]
|
||||
|
||||
# Drop Cap Check
|
||||
if prefix_parts and middle_parts:
|
||||
prefix_parts, middle_parts, middle_types = self._handle_drop_cap(
|
||||
prefix_parts, middle_parts, middle_types
|
||||
)
|
||||
|
||||
local_map = {}
|
||||
if prefix_parts:
|
||||
local_map["_prefix"] = "".join(prefix_parts)
|
||||
if suffix_parts:
|
||||
local_map["_suffix"] = "".join(suffix_parts)
|
||||
|
||||
# Middle processing
|
||||
placeholder_counter = 1
|
||||
result_parts = []
|
||||
tag_stack = []
|
||||
|
||||
i = 0
|
||||
while i < len(middle_parts):
|
||||
part = middle_parts[i]
|
||||
ptype = middle_types[i]
|
||||
|
||||
if ptype == 'translatable':
|
||||
result_parts.append(part)
|
||||
i += 1
|
||||
|
||||
elif ptype == 'tag':
|
||||
is_closing = part.startswith('</')
|
||||
if is_closing:
|
||||
if tag_stack:
|
||||
open_id, open_tag = tag_stack.pop()
|
||||
local_map[f"/{open_id}"] = part
|
||||
result_parts.append(f"φ/{open_id}φ")
|
||||
else:
|
||||
pid = str(placeholder_counter)
|
||||
placeholder_counter += 1
|
||||
local_map[pid] = part
|
||||
result_parts.append(f"φ{pid}φ")
|
||||
i += 1
|
||||
else:
|
||||
has_translatable_after = False
|
||||
for j in range(i + 1, len(middle_parts)):
|
||||
if middle_types[j] == 'translatable':
|
||||
has_translatable_after = True
|
||||
break
|
||||
elif middle_types[j] == 'tag' and middle_parts[j].startswith('</'):
|
||||
break
|
||||
|
||||
if has_translatable_after:
|
||||
pid = str(placeholder_counter)
|
||||
placeholder_counter += 1
|
||||
local_map[pid] = part
|
||||
result_parts.append(f"φ{pid}φ")
|
||||
tag_stack.append((pid, part))
|
||||
i += 1
|
||||
else:
|
||||
block_parts = []
|
||||
while i < len(middle_parts) and middle_types[i] != 'translatable':
|
||||
block_parts.append(middle_parts[i])
|
||||
i += 1
|
||||
if block_parts:
|
||||
block_html = "".join(block_parts)
|
||||
pid = str(placeholder_counter)
|
||||
placeholder_counter += 1
|
||||
local_map[pid] = block_html
|
||||
result_parts.append(f"φ{pid}φ")
|
||||
else:
|
||||
result_parts.append(part)
|
||||
i += 1
|
||||
|
||||
text_with_ph = "".join(result_parts)
|
||||
text_with_ph = re.sub(r'\s+', ' ', text_with_ph).strip()
|
||||
|
||||
# Merge consecutive placeholders
|
||||
def merge_match(m):
|
||||
full_match = m.group(0)
|
||||
pids = re.findall(r'φ(/?\d+)φ', full_match)
|
||||
if len(pids) <= 1:
|
||||
return full_match
|
||||
|
||||
merged_html = ""
|
||||
for pid in pids:
|
||||
if pid in local_map:
|
||||
merged_html += local_map[pid]
|
||||
del local_map[pid]
|
||||
|
||||
new_pid = pids[0] if pids[0].isdigit() else pids[0][1:]
|
||||
local_map[new_pid] = merged_html
|
||||
return f"φ{new_pid}φ"
|
||||
|
||||
text_with_ph = re.sub(r'(φ/?\d+φ)(φ/?\d+φ)+', merge_match, text_with_ph)
|
||||
|
||||
return text_with_ph, local_map
|
||||
|
||||
def _handle_drop_cap(self, prefix_parts: List[str], middle_parts: List[str], middle_types: List[str]):
|
||||
if not prefix_parts or not middle_parts:
|
||||
return prefix_parts, middle_parts, middle_types
|
||||
|
||||
prefix_text = ""
|
||||
for part in prefix_parts:
|
||||
if not part.startswith('<'):
|
||||
prefix_text = part.strip()
|
||||
|
||||
if not prefix_text or len(prefix_text) != 1 or not prefix_text.isupper():
|
||||
return prefix_parts, middle_parts, middle_types
|
||||
|
||||
first_middle_text = ""
|
||||
first_middle_idx = -1
|
||||
for i, (part, ptype) in enumerate(zip(middle_parts, middle_types)):
|
||||
if ptype == 'translatable':
|
||||
first_middle_text = part.strip()
|
||||
first_middle_idx = i
|
||||
break
|
||||
|
||||
if not first_middle_text:
|
||||
return prefix_parts, middle_parts, middle_types
|
||||
|
||||
is_drop_cap = False
|
||||
first_char = first_middle_text[0] if first_middle_text else ''
|
||||
if first_char.islower() or first_char.isupper():
|
||||
is_drop_cap = True
|
||||
|
||||
combined = prefix_text + first_middle_text.split()[0] if first_middle_text else ""
|
||||
if not (len(combined) >= 2 and combined.isalpha()):
|
||||
return prefix_parts, middle_parts, middle_types
|
||||
|
||||
new_prefix = []
|
||||
skip_until_close = False
|
||||
found_letter = False
|
||||
|
||||
for part in prefix_parts:
|
||||
if part.startswith('<') and not part.startswith('</'):
|
||||
skip_until_close = True
|
||||
elif part.startswith('</'):
|
||||
if skip_until_close:
|
||||
skip_until_close = False
|
||||
continue
|
||||
new_prefix.append(part)
|
||||
elif part.strip() == prefix_text:
|
||||
found_letter = True
|
||||
continue
|
||||
else:
|
||||
if not skip_until_close:
|
||||
new_prefix.append(part)
|
||||
|
||||
if found_letter:
|
||||
middle_parts = middle_parts.copy()
|
||||
middle_parts[first_middle_idx] = prefix_text + middle_parts[first_middle_idx]
|
||||
prefix_parts = new_prefix
|
||||
|
||||
return prefix_parts, middle_parts, middle_types
|
||||
|
||||
def _is_translatable_text(self, text: str) -> bool:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return False
|
||||
if re.search(r'[a-zA-Z]{3,}', text):
|
||||
return True
|
||||
if ' ' in text and re.search(r'[a-zA-Z]', text):
|
||||
return True
|
||||
if re.search(r'\d', text):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,70 @@
|
||||
import re
|
||||
from typing import Dict, Tuple, List, Optional
|
||||
from loguru import logger
|
||||
from src.utils import setup_logger
|
||||
|
||||
logger = setup_logger("format_restorer")
|
||||
|
||||
class FormatRestorer:
|
||||
"""
|
||||
Restores HTML formatting from placeholders.
|
||||
"""
|
||||
|
||||
PLACEHOLDER_REGEX = re.compile(r'φ(/?\d+)φ')
|
||||
|
||||
def restore(self, text_with_placeholders: str, placeholder_map: Dict[str, str]) -> Tuple[str, bool]:
|
||||
"""
|
||||
Restores HTML from text with placeholders.
|
||||
Returns (restored_html, success).
|
||||
"""
|
||||
if not placeholder_map:
|
||||
return text_with_placeholders or "", True
|
||||
|
||||
if not text_with_placeholders:
|
||||
prefix = placeholder_map.get("_prefix", "")
|
||||
suffix = placeholder_map.get("_suffix", "")
|
||||
return prefix + suffix, True
|
||||
|
||||
prefix = placeholder_map.get("_prefix", "")
|
||||
suffix = placeholder_map.get("_suffix", "")
|
||||
|
||||
inner_map = {k: v for k, v in placeholder_map.items() if not k.startswith("_")}
|
||||
|
||||
found_ids = set(self.PLACEHOLDER_REGEX.findall(text_with_placeholders))
|
||||
expected_ids = set(inner_map.keys())
|
||||
|
||||
success = True
|
||||
missing_ids = expected_ids - found_ids
|
||||
if missing_ids:
|
||||
logger.warning(f"Restoration warning: missing placeholders {missing_ids}")
|
||||
success = False
|
||||
|
||||
unknown_ids = found_ids - expected_ids
|
||||
if unknown_ids:
|
||||
real_unknowns = set()
|
||||
for pid in unknown_ids:
|
||||
if pid.startswith('/') and pid[1:] in expected_ids:
|
||||
continue
|
||||
real_unknowns.add(pid)
|
||||
|
||||
if real_unknowns:
|
||||
logger.warning(f"Restoration warning: unknown placeholders {real_unknowns}")
|
||||
success = False
|
||||
|
||||
def replace_match(match):
|
||||
pid = match.group(1)
|
||||
if pid in inner_map:
|
||||
return inner_map[pid]
|
||||
else:
|
||||
return ""
|
||||
|
||||
try:
|
||||
restored_inner = self.PLACEHOLDER_REGEX.sub(replace_match, text_with_placeholders)
|
||||
restored_html = prefix + restored_inner + suffix
|
||||
return restored_html, success
|
||||
except Exception as e:
|
||||
logger.error(f"Restoration failed: {e}")
|
||||
return prefix + self._strip_placeholders(text_with_placeholders) + suffix, False
|
||||
|
||||
def _strip_placeholders(self, text: str) -> str:
|
||||
return self.PLACEHOLDER_REGEX.sub("", text)
|
||||
@@ -0,0 +1,220 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
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 loguru import logger
|
||||
|
||||
from src.data_model import ManifestEntry
|
||||
from src.utils import setup_logger, ensure_directory
|
||||
|
||||
logger = setup_logger("llm_client")
|
||||
|
||||
# Default chunk save directory (can be overridden)
|
||||
DEFAULT_CHUNK_DIR = Path("tmp/chunks")
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiter for concurrency and RPM."""
|
||||
def __init__(self, requests_per_minute: int, concurrent_requests: int):
|
||||
self.semaphore = asyncio.Semaphore(concurrent_requests)
|
||||
self.min_interval = 60.0 / requests_per_minute if requests_per_minute > 0 else 0
|
||||
self.last_request_time = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self):
|
||||
await self.semaphore.acquire()
|
||||
async with self._lock:
|
||||
current_time = time.time()
|
||||
wait_time = self.min_interval - (current_time - self.last_request_time)
|
||||
if wait_time > 0:
|
||||
await asyncio.sleep(wait_time)
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def release(self):
|
||||
self.semaphore.release()
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Generic OpenAI-compatible API Client with short ID strategy."""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model: str = "gpt-3.5-turbo",
|
||||
requests_per_minute: int = 60, concurrent_requests: int = 5,
|
||||
extra_headers: Dict = None, chunk_dir: Path = None):
|
||||
|
||||
# Configure proxy client to avoid SOCKS issues and ensure connectivity
|
||||
import httpx
|
||||
import os
|
||||
|
||||
# Prefer HTTP proxy if available to avoid missing socksio support
|
||||
proxy_url = os.environ.get("http_proxy") or os.environ.get("https_proxy")
|
||||
http_client = httpx.AsyncClient(
|
||||
proxy=proxy_url,
|
||||
timeout=60.0,
|
||||
follow_redirects=True
|
||||
) if proxy_url else None
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
default_headers=extra_headers,
|
||||
http_client=http_client
|
||||
)
|
||||
self.model = model
|
||||
|
||||
self.rate_limiter = RateLimiter(requests_per_minute, concurrent_requests)
|
||||
self.prompts = self._load_prompts()
|
||||
self._chunk_counter = 0
|
||||
|
||||
# Chunk directory for debug output
|
||||
self.chunk_dir = chunk_dir or DEFAULT_CHUNK_DIR
|
||||
ensure_directory(self.chunk_dir)
|
||||
|
||||
def _load_prompts(self) -> Dict:
|
||||
try:
|
||||
with open("config/prompts.json", "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config/prompts.json: {e}")
|
||||
return {}
|
||||
|
||||
async def translate_chunk(self, items: List[ManifestEntry], glossary: Dict = None,
|
||||
instruction: str = None, mode: str = "bilingual") -> Dict[str, str]:
|
||||
"""
|
||||
Translate a chunk of items using short ID strategy.
|
||||
Returns: Dict[entry_id, translated_text]
|
||||
"""
|
||||
if not items: return {}
|
||||
|
||||
# Build prompt with short IDs
|
||||
id_map, prompt = self._build_prompt_with_short_ids(items)
|
||||
|
||||
try:
|
||||
# Build System Prompt
|
||||
base_sys_prompt = self.prompts.get("translation", {}).get("system",
|
||||
"You are a professional English to Chinese translator.")
|
||||
|
||||
if instruction:
|
||||
base_sys_prompt += f"\n\nBook Style Guide:\n{instruction}"
|
||||
|
||||
if glossary:
|
||||
glossary_text = "\n".join([f"{k} -> {v}" for k, v in glossary.items()])
|
||||
base_sys_prompt += f"\n\nTerminology:\n{glossary_text}"
|
||||
|
||||
# Short ID format instructions
|
||||
base_sys_prompt += """
|
||||
|
||||
Output Format:
|
||||
- Each line MUST start with #N: (keep this ID exactly as given)
|
||||
- Preserve any φXφ or φ/Xφ placeholders EXACTLY as-is
|
||||
- Only output translations, no explanations
|
||||
- Match the number of output lines to input lines"""
|
||||
|
||||
# Save chunk before translation
|
||||
chunk_id = self._save_chunk("before", prompt, base_sys_prompt)
|
||||
|
||||
logger.debug(f"Sending request to LLM (Chunk: {chunk_id}, Items: {len(items)})")
|
||||
|
||||
raw_response = await self._make_request(base_sys_prompt, prompt)
|
||||
|
||||
# Save chunk after translation
|
||||
self._save_chunk("after", raw_response, base_sys_prompt, chunk_id)
|
||||
|
||||
# Parse with short ID mapping
|
||||
results = self._parse_short_id_response(raw_response, id_map)
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Translation failed: {e}")
|
||||
return {item.entry_id: f"[Error - {str(e)}]" for item in items}
|
||||
|
||||
def _build_prompt_with_short_ids(self, items: List[ManifestEntry]) -> tuple:
|
||||
"""
|
||||
Build prompt with short IDs (#1, #2, ...).
|
||||
Returns: (id_map, prompt_text)
|
||||
"""
|
||||
id_map = {} # short_id -> entry_id
|
||||
lines = []
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
short_id = f"#{i}"
|
||||
id_map[short_id] = item.entry_id
|
||||
|
||||
# Clean text (remove extra whitespace)
|
||||
text = re.sub(r'\s+', ' ', item.original_text).strip()
|
||||
lines.append(f"{short_id}: {text}")
|
||||
|
||||
return id_map, "\n".join(lines)
|
||||
|
||||
def _parse_short_id_response(self, response: str, id_map: Dict[str, str]) -> Dict[str, str]:
|
||||
"""
|
||||
Parse response with short ID format.
|
||||
Returns: Dict[entry_id, translated_text]
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for line in response.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Match #N: pattern
|
||||
match = re.match(r'^#(\d+):\s*(.+)$', line)
|
||||
if match:
|
||||
short_id = f"#{match.group(1)}"
|
||||
translation = match.group(2).strip()
|
||||
|
||||
if short_id in id_map:
|
||||
full_id = id_map[short_id]
|
||||
results[full_id] = translation
|
||||
else:
|
||||
logger.warning(f"Unknown short ID in response: {short_id}")
|
||||
|
||||
return results
|
||||
|
||||
def _save_chunk(self, stage: str, content: str, system_prompt: str = None,
|
||||
chunk_id: str = None) -> str:
|
||||
"""Save chunk to tmp directory for debugging."""
|
||||
if chunk_id is None:
|
||||
self._chunk_counter += 1
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
chunk_id = f"{timestamp}_{self._chunk_counter:04d}"
|
||||
|
||||
filename = self.chunk_dir / f"chunk_{chunk_id}_{stage}.txt"
|
||||
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
if system_prompt and stage == "before":
|
||||
f.write("=== SYSTEM PROMPT ===\n")
|
||||
f.write(system_prompt)
|
||||
f.write("\n\n=== USER PROMPT ===\n")
|
||||
f.write(content)
|
||||
|
||||
logger.debug(f"Saved chunk: {filename}")
|
||||
return chunk_id
|
||||
|
||||
async def raw_chat_completion(self, system_prompt: str, user_prompt: str) -> str:
|
||||
"""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))
|
||||
async def _make_request(self, system_prompt: str, user_prompt: str) -> str:
|
||||
await self.rate_limiter.acquire()
|
||||
try:
|
||||
resp = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
temperature=0.3,
|
||||
)
|
||||
return resp.choices[0].message.content.strip()
|
||||
finally:
|
||||
self.rate_limiter.release()
|
||||
|
||||
async def close(self):
|
||||
await self.client.close()
|
||||
@@ -0,0 +1,74 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
from src.data_model import ManifestEntry
|
||||
from src.utils import setup_logger
|
||||
|
||||
logger = setup_logger("manifest_manager")
|
||||
|
||||
class ManifestManager:
|
||||
"""
|
||||
Manages the translation manifest (Source of Truth).
|
||||
Handles persistence and state updates.
|
||||
"""
|
||||
|
||||
def __init__(self, manifest_path: Path):
|
||||
self.manifest_path = manifest_path
|
||||
self.entries: List[ManifestEntry] = []
|
||||
self._entries_map: Dict[str, ManifestEntry] = {}
|
||||
|
||||
def load(self):
|
||||
"""Loads manifest from disk if it exists."""
|
||||
if not self.manifest_path.exists():
|
||||
logger.info(f"Manifest not found at {self.manifest_path}, starting empty.")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.entries = [ManifestEntry.model_validate(item) for item in data]
|
||||
self._rebuild_map()
|
||||
logger.info(f"Loaded {len(self.entries)} entries from manifest.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load manifest: {e}")
|
||||
raise
|
||||
|
||||
def save(self):
|
||||
"""Saves current state to disk."""
|
||||
try:
|
||||
# Pydantic v2: model_dump(mode='json') or just list dump
|
||||
data = [entry.model_dump(mode='json') for entry in self.entries]
|
||||
with open(self.manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
logger.info(f"Saved {len(self.entries)} entries to manifest.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save manifest: {e}")
|
||||
raise
|
||||
|
||||
def add_entries(self, new_entries: List[ManifestEntry]):
|
||||
"""
|
||||
Adds new entries to the manifest.
|
||||
If an entry with the same ID exists, it keeps the EXISTING one (to preserve translations).
|
||||
"""
|
||||
count = 0
|
||||
for entry in new_entries:
|
||||
if entry.entry_id not in self._entries_map:
|
||||
self.entries.append(entry)
|
||||
self._entries_map[entry.entry_id] = entry
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
logger.info(f"Added {count} new entries to manifest.")
|
||||
|
||||
def update_translation(self, entry_id: str, translation: str):
|
||||
"""Updates translation for a specific entry."""
|
||||
if entry_id in self._entries_map:
|
||||
self._entries_map[entry_id].translated_text = translation
|
||||
else:
|
||||
logger.warning(f"Attempted to update translation for unknown ID: {entry_id}")
|
||||
|
||||
def get_entry(self, entry_id: str) -> Optional[ManifestEntry]:
|
||||
return self._entries_map.get(entry_id)
|
||||
|
||||
def _rebuild_map(self):
|
||||
self._entries_map = {e.entry_id: e for e in self.entries}
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Translator Module - Handles translation of ManifestEntry items.
|
||||
|
||||
Key features:
|
||||
- Character-based chunking (~5000 chars per chunk)
|
||||
- Chapter-aware grouping (chunks don't cross file boundaries)
|
||||
- Concurrent translation with asyncio.gather
|
||||
- Progress tracking and error handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
from collections import defaultdict
|
||||
from loguru import logger
|
||||
|
||||
from src.data_model import ManifestEntry, BookProfile
|
||||
from src.llm_client import LLMClient
|
||||
from src.format_restorer import FormatRestorer
|
||||
from src.utils import setup_logger
|
||||
|
||||
logger = setup_logger("translator")
|
||||
|
||||
# Default chunk size in characters
|
||||
DEFAULT_CHUNK_SIZE = 5000
|
||||
# Maximum concurrent translations
|
||||
MAX_CONCURRENT = 5
|
||||
|
||||
|
||||
class Translator:
|
||||
"""
|
||||
Translates ManifestEntry items using LLM with chapter-aware chunking.
|
||||
Supports both sequential and concurrent translation modes.
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client: LLMClient, chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
max_concurrent: int = MAX_CONCURRENT):
|
||||
self.llm_client = llm_client
|
||||
self.chunk_size = chunk_size
|
||||
self.max_concurrent = max_concurrent
|
||||
self.restorer = FormatRestorer()
|
||||
|
||||
async def translate(self, entries: List[ManifestEntry], profile: BookProfile,
|
||||
concurrent: bool = True) -> List[ManifestEntry]:
|
||||
"""
|
||||
Translates all untranslated entries.
|
||||
|
||||
Args:
|
||||
entries: All manifest entries
|
||||
profile: Book profile with style guide
|
||||
concurrent: Use concurrent translation (default True)
|
||||
|
||||
Returns:
|
||||
The same entries list with translated_text populated
|
||||
"""
|
||||
untranslated = [e for e in entries if not e.translated_text]
|
||||
if not untranslated:
|
||||
logger.info("No new entries to translate.")
|
||||
return entries
|
||||
|
||||
logger.info(f"Found {len(untranslated)} entries to translate")
|
||||
|
||||
# Group by chapter (file_path)
|
||||
chapters = self._group_by_chapter(untranslated)
|
||||
logger.info(f"Grouped into {len(chapters)} chapters")
|
||||
|
||||
# Create all chunks
|
||||
all_chunks = []
|
||||
for file_path, chapter_entries in chapters.items():
|
||||
chapter_chunks = self._create_char_based_chunks(chapter_entries)
|
||||
for chunk in chapter_chunks:
|
||||
all_chunks.append((file_path, chunk))
|
||||
|
||||
total_chunks = len(all_chunks)
|
||||
logger.info(f"Created {total_chunks} chunks (avg ~{self.chunk_size} chars each)")
|
||||
|
||||
if concurrent:
|
||||
await self._translate_concurrent(all_chunks, profile)
|
||||
else:
|
||||
await self._translate_sequential(all_chunks, profile)
|
||||
|
||||
translated_count = sum(1 for e in entries if e.translated_text)
|
||||
logger.info(f"Translation complete: {translated_count}/{len(entries)} entries translated")
|
||||
return entries
|
||||
|
||||
async def _translate_concurrent(self, all_chunks: List, profile: BookProfile):
|
||||
"""Translate chunks concurrently with semaphore control."""
|
||||
semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||
completed = [0] # Use list for mutable counter in closure
|
||||
total = len(all_chunks)
|
||||
success = [0]
|
||||
failed = [0]
|
||||
|
||||
async def translate_chunk_task(file_path: str, chunk: List[ManifestEntry], idx: int):
|
||||
async with semaphore:
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if profile else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
entry.translated_text = results[entry.entry_id]
|
||||
success[0] += 1
|
||||
else:
|
||||
failed[0] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {idx} failed: {e}")
|
||||
failed[0] += len(chunk)
|
||||
finally:
|
||||
completed[0] += 1
|
||||
if completed[0] % 5 == 0 or completed[0] == total:
|
||||
logger.info(f"Progress: {completed[0]}/{total} chunks ({success[0]} translated)")
|
||||
|
||||
tasks = [
|
||||
translate_chunk_task(file_path, chunk, i)
|
||||
for i, (file_path, chunk) in enumerate(all_chunks)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
logger.info(f"Concurrent translation: {success[0]} success, {failed[0]} failed")
|
||||
|
||||
async def _translate_sequential(self, all_chunks: List, profile: BookProfile):
|
||||
"""Translate chunks sequentially."""
|
||||
total_chunks = len(all_chunks)
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for idx, (file_path, chunk) in enumerate(all_chunks):
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if profile else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
entry.translated_text = results[entry.entry_id]
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
logger.warning(f"Missing translation for: {entry.entry_id[-40:]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {idx} translation failed: {e}")
|
||||
fail_count += len(chunk)
|
||||
|
||||
if (idx + 1) % 10 == 0 or idx + 1 == total_chunks:
|
||||
logger.info(f"Progress: {idx + 1}/{total_chunks} chunks ({success_count} entries translated)")
|
||||
|
||||
logger.info(f"Sequential translation: {success_count} success, {fail_count} failed")
|
||||
|
||||
|
||||
async def translate_chapter(self, entries: List[ManifestEntry], file_path: str,
|
||||
profile: BookProfile) -> Dict[str, int]:
|
||||
"""
|
||||
Translate a single chapter.
|
||||
|
||||
Args:
|
||||
entries: All entries (will filter by file_path)
|
||||
file_path: Chapter file path to translate
|
||||
profile: Book profile
|
||||
|
||||
Returns:
|
||||
Dict with 'success' and 'failed' counts
|
||||
"""
|
||||
chapter_entries = [e for e in entries if e.file_path == file_path and not e.translated_text]
|
||||
|
||||
if not chapter_entries:
|
||||
logger.info(f"Chapter {file_path} has no untranslated entries")
|
||||
return {"success": 0, "failed": 0}
|
||||
|
||||
logger.info(f"Translating chapter: {file_path} ({len(chapter_entries)} entries)")
|
||||
|
||||
chunks = self._create_char_based_chunks(chapter_entries)
|
||||
logger.info(f"Created {len(chunks)} chunks")
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
chunk_chars = sum(len(e.original_text) for e in chunk)
|
||||
logger.debug(f"Chunk {i}/{len(chunks)}: {len(chunk)} entries, {chunk_chars} chars")
|
||||
|
||||
try:
|
||||
results = await self.llm_client.translate_chunk(
|
||||
chunk,
|
||||
instruction=profile.style_guide if profile else None,
|
||||
mode="bilingual"
|
||||
)
|
||||
|
||||
for entry in chunk:
|
||||
if entry.entry_id in results:
|
||||
entry.translated_text = results[entry.entry_id]
|
||||
|
||||
# Verify placeholder preservation
|
||||
if entry.placeholders:
|
||||
_, restored_ok = self.restorer.restore(
|
||||
entry.translated_text,
|
||||
entry.placeholders
|
||||
)
|
||||
if not restored_ok:
|
||||
logger.warning(f"Placeholder issue: {entry.entry_id[-40:]}")
|
||||
|
||||
success += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {i} failed: {e}")
|
||||
failed += len(chunk)
|
||||
|
||||
logger.info(f"Chapter done: {success} success, {failed} failed")
|
||||
return {"success": success, "failed": failed}
|
||||
|
||||
def _group_by_chapter(self, entries: List[ManifestEntry]) -> Dict[str, List[ManifestEntry]]:
|
||||
"""Group entries by file_path (chapter)."""
|
||||
chapters = defaultdict(list)
|
||||
for entry in entries:
|
||||
chapters[entry.file_path].append(entry)
|
||||
return dict(chapters)
|
||||
|
||||
def _create_char_based_chunks(self, entries: List[ManifestEntry]) -> List[List[ManifestEntry]]:
|
||||
"""
|
||||
Create chunks based on character count.
|
||||
|
||||
Each chunk contains approximately self.chunk_size characters.
|
||||
Chunks never cross chapter boundaries (entries from same file only).
|
||||
"""
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
for entry in entries:
|
||||
text_len = len(entry.original_text)
|
||||
|
||||
# If adding this entry exceeds limit and we have content, start new chunk
|
||||
if current_size + text_len > self.chunk_size and current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = []
|
||||
current_size = 0
|
||||
|
||||
current_chunk.append(entry)
|
||||
current_size += text_len
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
def get_chapter_stats(self, entries: List[ManifestEntry]) -> List[Dict]:
|
||||
"""
|
||||
Get statistics for each chapter.
|
||||
|
||||
Returns list of dicts with: file_path, total, translated, pending, chars
|
||||
"""
|
||||
chapters = self._group_by_chapter(entries)
|
||||
stats = []
|
||||
|
||||
for file_path, chapter_entries in sorted(chapters.items()):
|
||||
total = len(chapter_entries)
|
||||
translated = sum(1 for e in chapter_entries if e.translated_text)
|
||||
total_chars = sum(len(e.original_text) for e in chapter_entries)
|
||||
|
||||
# Get first text as title preview
|
||||
first_text = ""
|
||||
for e in chapter_entries:
|
||||
if e.original_text:
|
||||
first_text = e.original_text[:40].replace('\n', ' ')
|
||||
break
|
||||
|
||||
stats.append({
|
||||
"file_path": file_path,
|
||||
"title": first_text,
|
||||
"total": total,
|
||||
"translated": translated,
|
||||
"pending": total - translated,
|
||||
"chars": total_chars
|
||||
})
|
||||
|
||||
return stats
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
def setup_logger(name: str, log_file: Path = None, level=logging.INFO):
|
||||
"""Sets up a logger with the given name."""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(level)
|
||||
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
if log_file:
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(formatter)
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
return logger
|
||||
|
||||
def ensure_directory(path: Path):
|
||||
"""Ensures a directory exists."""
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1 @@
|
||||
print("Hello from python")
|
||||
Reference in New Issue
Block a user