diff --git a/archive/v0.11/main.py b/archive/v0.11/main.py
index 5194fef..0b2779b 100644
--- a/archive/v0.11/main.py
+++ b/archive/v0.11/main.py
@@ -102,13 +102,32 @@ async def run_pipeline(args):
await translator.translate(manifest_manager.entries, profile)
manifest_manager.save()
- await llm_client.close()
+ # Don't close here, wait until after backfill
+ # await llm_client.close()
+ pass
else:
logger.info("Skipping translation step.")
- # 5. Backfill
- backfiller = BackfillEngine()
- updated_structure = backfiller.backfill(structure, manifest_manager.entries, mode=args.mode)
+ # 5. Backfill (now async + LLM repair enabled)
+ # Reuse existing llm_client if available, otherwise create temporary one if needed?
+ # In this flow, llm_client is created inside the 'if not args.skip_translation' block.
+ # If skip_translation is True, llm_client is undefined.
+
+ backfill_llm_client = None
+ should_close_client = False
+
+ if 'llm_client' in locals() and llm_client:
+ backfill_llm_client = llm_client
+ elif api_key and not args.skip_translation:
+ # This case shouldn't happen because if not skip, we key llm_client above.
+ # But if skip_translation is True, we might still want repair?
+ # For now, let's only enable repair if translation occurred or if we explicitly create one.
+ # User said: "LLM features may fail" if no key.
+ pass
+
+ # Initialization
+ backfiller = BackfillEngine(llm_client=backfill_llm_client)
+ updated_structure = await 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)
@@ -124,6 +143,9 @@ async def run_pipeline(args):
logger.info(f"Pipeline completed! Output: {created_epub}")
+ if 'llm_client' in locals() and llm_client:
+ await llm_client.close()
+
except EpubTranslatorError as e:
logger.error(f"An error occurred: {e}")
sys.exit(1)
diff --git a/archive/v0.12/config/config.yaml b/archive/v0.12/config/config.yaml
new file mode 100644
index 0000000..742a1de
--- /dev/null
+++ b/archive/v0.12/config/config.yaml
@@ -0,0 +1,25 @@
+# LLM Configuration
+llm:
+ # Model name (e.g., gpt-4o, gpt-3.5-turbo, deepseek-chat)
+ model: "google/gemini-3-flash-preview"
+ #model: "gemini-3-pro-low"
+
+ # API Base URL (default is OpenAI)
+ #base_url: "https://api.gpt.ge/v1"
+ #base_url: "http://192.168.50.100:11434/v1"
+ base_url: "https://openrouter.ai/api/v1"
+
+ # Timeout for API requests in seconds
+ timeout: 60
+
+ # Rate Limiting
+ requests_per_minute: 60
+ concurrent_requests: 4
+
+# Translation Settings
+translation:
+ # Characters per chunk (approximate)
+ chunk_size: 6000
+
+ # System prompt instruction file (optional, overrides default if present)
+ # style_guide_path: "config/style_guide.txt"
diff --git a/archive/v0.12/config/prompts.json b/archive/v0.12/config/prompts.json
new file mode 100644
index 0000000..7b74b31
--- /dev/null
+++ b/archive/v0.12/config/prompts.json
@@ -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}}"
+ }
+}
\ No newline at end of file
diff --git a/archive/v0.12/doc/OPERATION_MANUAL.md b/archive/v0.12/doc/OPERATION_MANUAL.md
new file mode 100644
index 0000000..65b9f69
--- /dev/null
+++ b/archive/v0.12/doc/OPERATION_MANUAL.md
@@ -0,0 +1,131 @@
+# Operation Manual & Change Log
+
+## Core Principles
+
+1. **Modularity**: The system is divided into three distinct phases (Preprocessing, Translation, Assembly) with clear boundaries.
+2. **Immutability**: `book_structure.json` is generated once during preprocessing and should not be modified by subsequent steps.
+3. **Source of Truth**: `manifest.json` is the single source of truth for translations.
+4. **Idempotency**: Translation steps can be retried without side effects (existing translations are preserved).
+
+## Directory Structure
+
+* `pipeline/`: Executable scripts for each stage.
+ * `01_preprocess.py`: Clean EPUB, generate structure, extract text.
+ * `02_translate.py`: Translate text in manifest.
+ * `03_assemble.py`: Apply translations and build final EPUB.
+* `src/`: Core logic modules.
+ * `preprocessing/`: Cleaning, extraction, profiling.
+ * `translation/`: LLM integration, manifest management.
+ * `assembly/`: Backfilling, EPUB building.
+ * `common/`: Shared data models and utils.
+* `work/`: Working directory for intermediate files (ignored by git).
+
+## Pipeline Usage
+
+### Step 1: Preprocessing
+```bash
+python pipeline/01_preprocess.py inputs/my_book.epub
+```
+Generates `work/my_book/book_structure.json` and `manifest.json`.
+
+### Step 2: Translation
+```bash
+python pipeline/02_translate.py --input-epub inputs/my_book.epub
+```
+Translates entries in `manifest.json`. ensuring `.env` has `OPENAI_API_KEY`.
+
+### Step 3: Restore Format (NEW)
+```bash
+python pipeline/03_restore_format.py inputs/my_book.epub
+```
+**Optimizes and validates translations**:
+1. Applies "Pangu" spacing (inserts space between Chinese and English/Numbers).
+2. Restores HTML tags using placeholders.
+3. Attempts **LLM Auto-Repair** if validation fails.
+4. Saves result to `manifest.json` (`translated_html` field).
+
+### Step 4: Build EPUB
+```bash
+# Bilingual Output (Default)
+python pipeline/04_build_epub.py inputs/my_book.epub --bilingual
+
+# Target Language Output
+python pipeline/04_build_epub.py inputs/my_book.epub
+```
+**Pure assembly**: Injects the pre-validated `translated_html` into the EPUB structure. Fast and deterministic.
+
+## Configuration
+
+System settings are managed via `config/config.yaml` and environment variables.
+
+### `config/config.yaml`
+Control LLM parameters and translation behavior:
+```yaml
+llm:
+ model: "gpt-3.5-turbo" # LLM Model Name
+ base_url: "https://api.openai.com/v1"
+ timeout: 60
+ requests_per_minute: 60 # Rate limiting
+ concurrent_requests: 5 # Parallel chunks
+
+translation:
+ chunk_size: 4000 # Characters per chunk
+```
+
+### Environment Variables (`.env`)
+Security-sensitive credentials must be set here:
+```bash
+OPENAI_API_KEY=sk-... # Required
+OPENAI_BASE_URL=... # Optional override for config
+```
+
+## Known Issues & Troubleshooting
+
+### AuthenticationError (OpenRouter etc.)
+If you see `AuthenticationError` despite having the correct `base_url` in config:
+1. Check if you have a stale `OPENAI_API_KEY` in your shell environment.
+2. Environment variables **override** `.env` files.
+3. Fix: Run `unset OPENAI_API_KEY` (and `OPENAI_BASE_URL`) before running the script.
+
+### Missing/Unknown Placeholders
+* **Logs**: `WARNING - Restoration warning: missing placeholders...`
+* **Cause**: The LLM translation didn't preserve the exact `φXφ` tags.
+* **Fix**:
+ 1. The system will now attempt to **auto-repair** using the LLM during Assembly.
+ 2. If that fails, check logs. In some cases (e.g., complex HTML entities like `&`), the extractor might have degraded to plain text.
+ 3. (Fixed in v0.11) Enhanced `FormatExtractor` now handles HTML entities correctly, preventing phantom placeholder hallucinations.
+
+## Change Log
+
+### [2026-02-01] Pipeline Separation & Formatting
+* **Architecture**: Decoupled "Restoration" from "Assembly" into a 4-step pipeline.
+ * **New Step 3**: `03_restore_format.py` handles formatting, spacing, and repair. Saves to `translated_html`.
+ * **New Step 4**: `04_build_epub.py` handles pure EPUB generation.
+* **Data Model**: Added `translated_html` to `ManifestEntry` as the "Gold Master" formatted content.
+* **UX**: Added **Pangu Spacing** (Auto-spacing between CJK and ASCII) in Restoration step.
+* **Optimization**: `RestorationEngine` is now idempotent (skips processing if `translated_html` exists). Added `--force-restore` flag.
+* **Fix**: `main.py` updated to orchestrate the new 4-stage pipeline.
+
+### [2026-01-31] Robustness & Repair
+* **Feature**: Added **LLM-based Placeholder Repair** in Assembly stage. If placeholders mismatch, the system asks the LLM to fix the tags without changing text.
+* **Fix**: Solved `FormatExtractor` "phantom placeholders" issue by correctly unescaping HTML entities during integrity checks.
+* **Fix**: Resolved **Duplicate ID** issue in LLM response parsing. Now recursively strips repeated headers (e.g., `#12: #12: ...`) to prevent them from leaking into the translation.
+* **Tweak**: Updated `pipeline/03_assemble.py` to be async and load LLM config.
+
+### [2026-01-30] Performance Improvements
+* **Concurrency Fix**: Resolved issue where `concurrent_requests` in `config.yaml` was ignored by the Translator engine. Now `main.py` and `pipeline/02_translate.py` correctly propagate this setting, allowing faster translation with higher limits (e.g., for local LLMs or high-rate-limit providers).
+
+### [2026-01-28] Bug Fixes
+* **Fix Cover Image**: Resolved issue where book cover execution was missing in the final EPUB. Added `cover_image_id` tracking in `BookStructure` and restored proper OPF metadata in `BilingualBuilder`.
+
+### [2026-01-27] Externalized Configuration
+* **Config**: Added `config/config.yaml` for tuning parameters (LLM model, RPM, Chunk Size).
+* **Logic**: `pipeline/02_translate.py` now loads settings from `config.yaml`.
+* **Dependency**: Added `PyYAML` to `requirements.txt`.
+
+### [2026-01-27] Architecture Refactoring
+* **Restructured**: Moved source files into `src/preprocessing`, `src/translation`, `src/assembly`, `src/common`.
+* **Pipeline**: Created individual pipeline scripts in `pipeline/`.
+* **Refactor**: Renamed `fine_grained_extractor` to `text_extractor`, `translator` to `translator_engine`, etc.
+* **Logic**: Enforced 100% text coverage check in `format_extractor.py` (removed 95% threshold).
+* **Docs**: Created this Operation Manual.
diff --git a/archive/v0.12/doc/architecture_flow.md b/archive/v0.12/doc/architecture_flow.md
new file mode 100644
index 0000000..b487697
--- /dev/null
+++ b/archive/v0.12/doc/architecture_flow.md
@@ -0,0 +1,100 @@
+# ePub Bilingual Translator - Architecture & Data Flow (Revised)
+
+本文档详细描述了程序处理一个 EPUB 文件的完整生命周期。整个流程旨在实现**结构稳定性**(不丢段落)与**内容精细度**(不丢格式)的最佳平衡。
+
+## High Level Data Flow
+
+```mermaid
+graph TD
+ Input[Input EPUB] --> Cleaner[EpubCleaner]
+ Cleaner --> CleanedEPUB[1. Cleaned EPUB Temp]
+
+ CleanedEPUB --> Profiler[Book Profiler]
+ Profiler -->|Identify| Profile[Book Profile / Style]
+
+ CleanedEPUB --> Extractor[FineGrainedExtractor]
+
+ subgraph Extraction
+ Extractor -->|Step 1: P/H Tags| P[Source Paragraph]
+ P -->|Step 2: FormatExtract| PhText[Analyzed Text]
+ PhText -->|Register| Manifest[Manifest DB]
+ end
+
+ Manifest -->|Batch| LLM[LLM Translation]
+ Profile -->|Prompt Context| LLM
+
+ LLM -->|Translation| Manifest
+
+ Manifest --> Restorer[FormatRestorer]
+ Restorer -->|Reconstruct HTML| TargetHtml[Target HTML]
+
+ CleanedEPUB --> Backfiller[Backfill Engine]
+ TargetHtml --> Backfiller
+
+ Backfiller -->|Bilingual/Chinese Mode| DOM[Final DOM]
+ DOM --> Builder[BilingualBuilder]
+ Builder --> Output[Output EPUB]
+```
+
+## Detailed Workflow
+
+### 1. Preprocessing (预处理)
+**模块**: `src/epub_cleaner.py`
+* **Flatten Structure**: 消除嵌套 `div`,统一转为 `
`,消除结构性漏译风险。
+* **Auto-Fix**: 修复 TOC 死链、缺失 UID、由于 `ebooklib` bug 导致的样式丢失。
+* **Result**: 产生一个标准的临时文件,后续所有操作基于此文件,不再受原始糟糕格式影响。
+
+### 2. Intelligent Extraction (智能提取)
+**模块**: `src/fine_grained_extractor.py` + `src/format_extractor.py`
+
+#### A. 结构层 (Macro)
+使用 `FineGrainedExtractor` 锁定所有正文元素 (`p`, `h1`-`h6`)。
+* **Filter**: 排除页码、页眉脚。
+* **Optimization**: 针对目录章节,识别 **罗马数字 (I, II)**、**单独数字 (1, 2)**、**修饰符 (***)**,这些内容**不送翻译**,直接在回填时保留原文,以维持原书排版美感。
+
+#### B. 内容层 (Micro)
+对每个提取的段落调用 `FormatExtractor`:
+* **Inline Style**: 将 ``, `` 转为配对占位符 `φ1φ...φ/1φ`。
+* **Formula Protection**: 识别 $E=mc^2$ 等数学公式,保护为不可变占位符。
+* **Drop Cap Handling**:
+ - 原始: `The`
+ - 提取给 LLM: "The" (完整单词,无格式干扰)
+ - 记录: Prefix 包含 Drop Cap 样式。
+
+### 3. Manifest Management (清单管理)
+**模块**: `src/manifest_manager.py`
+Manifest 是系统的**核心状态中心 (Source of Truth)**。
+* **作用**: 解耦提取和翻译。提取器只管往 Manifest 填数据,翻译器只管从 Manifest 取数据。
+* **Persistence**: 支持中断续传,翻译进度实时保存。
+
+### 4. Translation with Profiling (翻译)
+**模块**: `src/book_profiler.py` & `src/translator.py`
+* **Profiling**: 在翻译前,抽取部分文本分析书籍的类型(技术、小说、诗歌)、核心术语和语言风格,生成 `System Prompt`。
+* **Translation**: 这是纯文本层面的转换,LLM 处理的是带有 `φ` 占位符的文本。
+
+### 5. Robust Restoration (健壮还原)
+**模块**: `src/format_restorer.py`
+负责将 LLM 返回的文本还原为 HTML。
+* **Drop Cap Logic**:
+ - **原文回填**: 需要 Prefix `T`。
+ - **译文回填**: **丢弃** Drop Cap Prefix。中文不需要首字母下沉,否则会出现 "T这本书..." 的怪诞结果。
+* **Error Handling**:
+ - **Missing Placeholders**: 如果 LLM 丢了 `φ1φ`,自动在末尾补全或报错重试。
+ - **Hallucinated Placeholders**: 移除 LLM 臆造的不存在 ID。
+
+### 6. Backfill Strategy (回填策略)
+**模块**: `src/fine_grained_extractor.py` (backfill method)
+支持多种模式,且**严格遵循一对一 (One-to-One) 映射**,绝不依赖顺序,而是依赖元素的内存引用或唯一 ID。
+
+* **Mode A: Bilingual (双语)**
+ - 保留原文 DOM。
+ - 在原文后 `append` 一个新元素 `
译文
`。
+ - 样式继承:译文元素复制原文的 `margin`, `text-align` 等关键样式。
+
+* **Mode B: Chinese Only (仅中文)**
+ - **Replace**: 直接用译文元素替换原文元素。
+ - **Drop Cap 适配**: 此时译文通常为普通段落,不再保留首字母下沉样式,以符合中文排版习惯。
+
+### 7. Assembly (组装)
+**模块**: `src/bilingual_builder.py`
+将内存中的 DOM 序列化,重新打包资源,生成最终 EPUB。
diff --git a/archive/v0.12/doc/preprocessing_backfill.md b/archive/v0.12/doc/preprocessing_backfill.md
new file mode 100644
index 0000000..f6b28a6
--- /dev/null
+++ b/archive/v0.12/doc/preprocessing_backfill.md
@@ -0,0 +1,180 @@
+# 预处理与回填层技术文档
+
+本文档详细描述 EPUB 双语翻译器的预处理(Preprocessing)和回填(Backfill)层的技术方案、数据结构、问题解决方案,用于指导代码开发、调试和维护。
+
+---
+
+## 1. 架构概览
+
+```
+┌─────────────┐ ┌────────────────┐ ┌───────────────────┐
+│ EPUB 文件 │ ──► │ EpubCleaner │ ──► │ book_structure.json│
+└─────────────┘ └────────────────┘ └───────────────────┘
+ │
+ ▼
+ ┌────────────────┐ ┌───────────────────┐
+ │FineGrainedExt. │ ──► │ manifest.json │
+ └────────────────┘ └───────────────────┘
+ │
+ ┌────────────────┐ │
+ │ BackfillEngine │ ◄───────────┘
+ └────────────────┘
+ │
+ ▼
+ ┌────────────────┐ ┌───────────────────┐
+ │BilingualBuilder│ ──► │ 输出 EPUB │
+ └────────────────┘ └───────────────────┘
+```
+
+---
+
+## 2. 目录结构
+
+```
+work/
+ ├── {book_name}/ # 每本书独立的工作目录
+ │ ├── book_structure.json # 书籍结构(会被复用)
+ │ ├── manifest.json # 翻译清单(持久化,Source of Truth)
+ │ └── assets/ # 解压出的 EPUB 资源文件
+ │ └── OEBPS/images/... # 保留原始路径结构
+ └── translations/ # (可选) 翻译记忆或其他中间文件
+```
+
+**关键设计**:`book_structure.json` **默认复用**。为避免 UUID 不一致导致回填失败,除非显式指定强制清理,否则程序优先读取现有的结构文件。
+
+---
+
+## 3. 数据结构定义
+
+### 3.1 BookStructure (book_structure.json)
+
+记录书籍的“骨架”,确保翻译后的章节能按正确顺序和层级重组。
+
+```json
+{
+ "metadata": {
+ "title": "书名",
+ "author": "作者",
+ "language": "en",
+ "identifier": "ISBN或UUID"
+ },
+ "spine": ["item_id_1", "item_id_2", ...], // 阅读顺序
+ "resources": {
+ "item_id_1": {
+ "href": "OEBPS/chapter1.xhtml",
+ "media_type": "application/xhtml+xml",
+ "content": "...", // 清理并注入ID后的HTML内容
+ "properties": "nav"
+ },
+ ...
+ }
+}
+```
+
+### 3.2 ManifestEntry (manifest.json)
+
+翻译清单是全生命周期的核心,记录了所有待翻译段落的状态。
+
+```json
+[
+ {
+ "entry_id": "OEBPS/c3Z.xhtml#uuid-603ff3fb",
+ "file_path": "OEBPS/c3Z.xhtml",
+ "element_id": "uuid-603ff3fb",
+ "original_text": "φ1φHelloφ/1φ world.",
+ "placeholders": {
+ "1": "",
+ "/1": "",
+ "_prefix": "",
+ "_suffix": "
"
+ },
+ "translated_text": "φ1φ你好φ/1φ 世界。",
+ "context": "body"
+ }
+]
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `entry_id` | str | **全局唯一ID** (`file_path#element_id`),用于精准追踪。 |
+| `file_path` | str | 标识该段落属于哪一章,用于按章分组批处理。 |
+| `element_id` | str | HTML DOM 元素的 ID,回填时的锚点。 |
+| `original_text` | str | 经过智能抽提和占位符化后的文本,发送给 LLM。 |
+| `placeholders` | Dict | 格式映射表,用于还原 HTML 结构。 |
+| `translated_text`| str | 翻译结果(含占位符),初始为 null。 |
+
+---
+
+## 4. 预处理与抽提流程 (Extraction)
+
+流程由 `FormatExtractor` 驱动,分为三个阶段:
+
+### Step 1: 结构索引 (Structure Indexing)
+* **动作**: 解析 OPF 文件,提取 Spine 和 Metadata。
+* **目的**: 建立骨架,确定处理顺序。
+
+### Step 2: 语义识别 (Semantic Detection)
+* **动作**: 使用 `HeadingDetector` 分析 DOM 节点。
+* **逻辑**:
+ * 匹配 `h1`-`h6` 正则,区分 `Chapter`(章)与 `Section`(节)。
+ * 识别 `blockquote` 或特定 class 判定 `Epigraph`(引言)。
+* **目的**: 为 LLM 提供差异化的 Prompt(例如翻译标题时不要加句号)。
+
+### Step 3: 智能抽提 (Smart Extraction - `_smart_extract_v3`)
+这是核心算法,将 HTML 转换为“纯文本+占位符”。
+
+1. **首尾分离 (Prefix/Suffix Separation)**:
+ * 将包裹文本的外层标签(如 ``, `div`)剥离到 `_prefix` 和 `_suffix`。
+ * **目的**: 极大减少 LLM 输入 Token,且防止 LLM 随意修改外层布局。
+
+2. **首字下沉处理 (Drop Cap Handling)**:
+ * 检测并合并被 `` 单独包裹的首字母(如 `O` + `nce` → `Once`)。
+ * **目的**: 修复语意割裂,让 LLM 看到完整的单词。
+
+3. **占位符化 (Placeholder Mapping)**:
+ * 将内联标签(``, ``)或公式替换为短码 `φIDφ`。
+ * **目的**: 保护 HTML 属性不被“翻译”,降低噪声干扰。
+
+4. **完整性校验 (Integrity Check)**:
+ * **逻辑**: 抽提后的文本(去占位符)与原始纯文本进行归一化比对,要求覆盖率 **100%**。
+ * **兜底**: 若校验失败(如误删内容),回退到简单模式(只剥离首尾标签)。
+
+---
+
+## 5. 构建与回填流程 (Backfill & Build)
+
+### 5.1 翻译回填
+* 根据 `entry_id` 找到对应的 DOM 节点。
+* 使用 `FormatRestorer` 将 `translated_text` 中的占位符(`φ1φ`)还原为原始 HTML 标签(``)。
+* 根据模式(双语/单语)决定将新节点插入到原文后还是替换原文。
+
+### 5.2 链接修复 (Link Repair)
+在 `BilingualBuilder` 中执行:
+1. **ID 补全**: 为缺失 ID 的 TOC 节点自动生成 UUID。
+2. **死链检测**: 检查 TOC/Nav 指向的文件是否存在。
+3. **模糊修复**: 尝试通过文件名后缀匹配(解决路径前缀变更问题)或特定重定向(如 `c0.xhtml` -> `cover.xhtml`)。
+4. **坏死剔除**: 无法修复的死链将从目录中移除。
+
+### 5.3 CSS 样式恢复
+`EbookLib` 默认可能会重写 `` 导致样式丢失。
+* **逻辑**:
+ * 收集所有 CSS 资源。
+ * 在构建每个 HTML Item 时,显式计算 HTML 到 CSS 的**相对路径**。
+ * 强制调用 `item.add_link(..., rel='stylesheet', type='text/css')` 注入引用。
+
+---
+
+## 6. 常见问题排查
+
+### 6.1 UUID 不匹配
+**现象**: `WARNING - Element uuid-xxx not found`.
+**原因**: 手动删除了 `book_structure.json` 但保留了 `manifest.json`,导致重新生成的 HTML ID 与清单记录不一致。
+**解决**: 清空 `work/BookName` 目录重新运行,或确保两个 JSON 文件版本一致。
+
+### 6.2 样式丢失
+**现象**: 打开书面目全非,只有黑白文字。
+**检查**: 解压 EPUB,查看 HTML `` 是否有 ``。如果没有,检查 `BilingualBuilder` 的 Step 4 逻辑。
+
+### 6.3 翻译错位
+**现象**: 译文出现在了错误的位置。
+**检查**: 确认 `entry_id` 生成逻辑是否包含文件名,且文件名在处理过程中未被意外修改。
diff --git a/archive/v0.12/doc/translation_layer.md b/archive/v0.12/doc/translation_layer.md
new file mode 100644
index 0000000..f5f10e3
--- /dev/null
+++ b/archive/v0.12/doc/translation_layer.md
@@ -0,0 +1,108 @@
+# Translation Layer Technical Documentation
+
+This document details the architecture of the **Translation Layer**, which operates primarily on `manifest.json`.
+
+> **Core Principle**: The Translation Layer is **decoupled** from the EPUB file format. It reads translatable units from `manifest.json`, processes them using an LLM, and writes translations back to `manifest.json`. It does **not** read or parse the EPUB file directly.
+
+---
+
+## 1. Architecture Overview
+
+### Data Flow
+
+```mermaid
+graph LR
+ A[manifest.json] -->|Load| B(ManifestManager)
+ B -->|Entries| C{Translator Orchestrator}
+ C -->|Sample Text| D[BookProfiler]
+ D -->|Style Guide| C
+ C -->|Chunks| E[LLMClient]
+ E -->|Translation| C
+ C -->|Update| B
+ B -->|Save| A[manifest.json]
+```
+
+1. **Input**: `manifest.json` (Generated by Preprocessing Layer).
+2. **Process**:
+ * **Profiling**: Analyze text samples to generate a `BookProfile` (style, tone, terminology).
+ * **Translation**: Batch entries into chunks, send to LLM, receive translations.
+3. **Output**: `manifest.json` (Updated with `translated_text` fields).
+
+---
+
+## 2. Core Modules (`src/translation/`)
+
+### 2.1 ManifestManager (`manifest_manager.py`)
+* **Role**: The interface for the "Source of Truth".
+* **Responsibility**:
+ * Load `manifest.json`.
+ * Provide list of `ManifestEntry` objects.
+ * Save updates back to disk.
+* **Key Method**: `update_translation(entry_id, translation)`
+
+### 2.2 Translator Engine (`translator_engine.py`)
+* **Role**: Orchestrates the translation process.
+* **Responsibility**:
+ * **Filtering**: Identify untranslated entries.
+ * **Grouping**: Group entries by chapter (file path) to maintain context.
+ * **Chunking**: Create character-based chunks (default ~5000 chars) that do not cross chapter boundaries.
+ * **Concurrency**: Manage async workers (default 5 concurrent tasks).
+* **Input**: `List[ManifestEntry]`, `BookProfile`.
+* **Output**: Updates `ManifestEntry` objects in-place.
+
+### 2.3 LLM Client (`llm_client.py`)
+* **Role**: Handles raw communication with the LLM Provider (OpenAI compatible).
+* **Responsibility**:
+ * **Prompt Engineering**: Construct Short-ID based prompts.
+ * **Rate Limiting**: Control RPM (Requests Per Minute).
+ * **Retry Logic**: Exponential backoff for API failures.
+ * **Logging**: Save raw request/response pairs to `work/{book}/chunks/` for debugging.
+
+### 2.4 Book Profiler (`../preprocessing/profiler.py`)
+* **Note**: While located in preprocessing, it is often invoked at the start of the translation phase.
+* **Role**: Generates a style guide.
+* **Mechanism**: Extracts a sample (Intro + Random segments) from `manifest.json` entries and asks the LLM to analyze author style.
+
+---
+
+## 3. Short ID Strategy
+
+To optimize token usage and ensuring mapping accuracy, we use a **Short ID** system for LLM interaction.
+
+**Prompt Format**:
+```text
+#1: First paragraph text...
+#2: Second paragraph text...
+```
+
+**Response Format**:
+```text
+#1: 第一段翻译...
+#2: 第二段翻译...
+```
+
+The `LLMClient` maintains a mapping of `Short ID (#N)` <-> `Entry ID (File#UUID)` for each chunk lifecycle.
+
+---
+
+## 4. Pipeline Usage
+
+The translation is executed via the standalone pipeline script:
+
+```bash
+python pipeline/02_translate.py --input-epub inputs/my_book.epub
+```
+* **--input-epub**: Uses the filename to locate the `work/` directory.
+* **--book-name**: Alternatively, specify the book folder name directly.
+
+### Dependencies
+* Environment variables must be set in `.env`:
+ * `OPENAI_API_KEY`
+ * `OPENAI_BASE_URL` (Optional)
+
+---
+
+## 5. Development & Debugging
+
+* **Chunk Logs**: Check `.work/{book}/chunks/` to see exactly what was sent to and received from the LLM.
+* **Idempotency**: The translation script skips entries that already have `translated_text`. To re-translate, you must manually clear `translated_text` in `manifest.json` or delete the manifest (to restart from preprocessing).
diff --git a/archive/v0.12/main.py b/archive/v0.12/main.py
new file mode 100644
index 0000000..bce44c7
--- /dev/null
+++ b/archive/v0.12/main.py
@@ -0,0 +1,172 @@
+import argparse
+import sys
+import os
+import asyncio
+from pathlib import Path
+from dotenv import load_dotenv
+from src.common.config import load_global_config
+
+from src.preprocessing.epub_cleaner import EpubCleaner
+from src.preprocessing.profiler import BookProfiler
+from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.translation.manifest_manager import ManifestManager
+from src.translation.translator_engine import Translator
+from src.translation.llm_client import LLMClient
+from src.assembly.restoration_engine import RestorationEngine
+from src.assembly.backfiller import BackfillEngine
+from src.assembly.builder import BilingualBuilder
+from src.common.data_model import BookStructure
+from src.common.utils import setup_logger, ensure_directory
+from src.common.exceptions import EpubTranslatorError
+
+logger = setup_logger("main")
+
+# Unified work directory structure
+# .work/
+# ├── {book_name}/
+# │ ├── book_structure.json
+# │ ├── manifest.json
+# │ ├── assets/
+# │ └── chunks/
+
+from src.common.paths import get_work_dirs
+
+
+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 Config
+ config = load_global_config()
+ llm_conf = config.get("llm", {})
+ trans_conf = config.get("translation", {})
+
+ api_key = llm_conf.get("api_key")
+ # Base URL and Model come from config if not overridden
+ base_url = llm_conf.get("base_url")
+ # CLI model arg overrides config model, which overrides default
+ model = args.model if args.model else llm_conf.get("model", "gpt-3.5-turbo")
+
+ if not api_key:
+ logger.warning("OPENAI_API_KEY not found in env or config. 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
+ # Initialize LLM Client (Centralized)
+ llm_client = None
+ if api_key:
+ llm_client = LLMClient(
+ api_key=api_key,
+ base_url=base_url,
+ model=model,
+ requests_per_minute=llm_conf.get("requests_per_minute", 60),
+ concurrent_requests=llm_conf.get("concurrent_requests", 5),
+ chunk_dir=work["chunks"]
+ )
+
+ # 4. Translation
+ if not args.skip_translation:
+ if not llm_client:
+ logger.error("Cannot translate without API Key. Use --skip-translation to test pipeline.")
+ sys.exit(1)
+
+ # Profiling
+ profiler = BookProfiler(llm_client)
+ profile = await profiler.analyze(manifest_manager.entries)
+ logger.info(f"Book Profile: {profile}")
+
+ # Translation
+ target_chunk_size = trans_conf.get("chunk_size", 5000)
+ concurrent_reqs = llm_conf.get("concurrent_requests", 5)
+ translator = Translator(llm_client, chunk_size=target_chunk_size, max_concurrent=concurrent_reqs)
+ await translator.translate(manifest_manager.entries, profile)
+ manifest_manager.save()
+ else:
+ logger.info("Skipping translation step.")
+
+ # 5. Restoration (Format + Spacing + Repair)
+ logger.info("Restoring format & applying spacing...")
+ # RestorationEngine handles Pangu spacing, FormatRestorer, and LLM Repair
+ restorer = RestorationEngine(llm_client)
+ # Default to skipping if already done, unless forced
+ force_restore = getattr(args, 'force_restore', False)
+ restore_success = await restorer.restore_entries(manifest_manager.entries, force=force_restore)
+ manifest_manager.save() # Save translated_html
+ logger.info(f"Restoration complete. {restore_success} entries validated.")
+
+ # 6. Backfill (Pure Injection)
+ logger.info("Injecting content into EPUB structure...")
+ backfiller = BackfillEngine() # Pure injection, no dependencies
+ updated_structure = await backfiller.backfill(structure, manifest_manager.entries, mode=args.mode)
+
+ # 7. Assembly - Pass original EPUB for TOC preservation
+ builder = BilingualBuilder(work["root"], original_epub_path=input_path)
+
+ if args.mode == "bilingual":
+ output_filename = f"bilingual_{input_path.name}"
+ else:
+ output_filename = f"translated_{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}")
+
+ if llm_client:
+ await llm_client.close()
+
+ 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=None, help="LLM Model to use (overrides config)")
+ parser.add_argument("--bilingual", action="store_true", help="Output bilingual version (default is target language only)")
+ parser.add_argument("--skip-translation", action="store_true", help="Skip LLM translation (for testing)")
+ parser.add_argument("--force-restore", action="store_true", help="Force re-run format restoration/repair even if translated_html exists")
+ parser.add_argument("--force-clean", action="store_true", help="Force re-clean EPUB even if book_structure exists")
+
+ args = parser.parse_args()
+
+ # Map boolean flag to mode string
+ args.mode = "bilingual" if args.bilingual else "target_only"
+
+ asyncio.run(run_pipeline(args))
+
+if __name__ == "__main__":
+ main()
+
diff --git a/archive/v0.12/pipeline/01_preprocess.py b/archive/v0.12/pipeline/01_preprocess.py
new file mode 100644
index 0000000..e20d75b
--- /dev/null
+++ b/archive/v0.12/pipeline/01_preprocess.py
@@ -0,0 +1,69 @@
+import argparse
+import sys
+import os
+from pathlib import Path
+
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.preprocessing.epub_cleaner import EpubCleaner
+from src.preprocessing.text_extractor import FineGrainedExtractor
+from src.translation.manifest_manager import ManifestManager
+from src.common.data_model import BookStructure
+from src.common.utils import setup_logger, ensure_directory
+from src.common.exceptions import EpubTranslatorError
+from src.common.paths import get_work_dirs
+
+logger = setup_logger("pipeline_preprocess")
+
+def run_preprocess(args):
+ input_path = Path(args.input_epub)
+ if not input_path.exists():
+ logger.error(f"Input file not found: {input_path}")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ ensure_directory(work["root"])
+
+ structure_path = work["structure"]
+ manifest_path = work["manifest"]
+
+ try:
+ # 1. Clean / Load Structure
+ if structure_path.exists() and not args.force:
+ logger.info(f"Reusing existing structure: {structure_path}")
+ structure = BookStructure.load(structure_path)
+ else:
+ logger.info("Cleaning EPUB...")
+ cleaner = EpubCleaner(input_path, work["root"])
+ structure_path = cleaner.clean()
+ structure = BookStructure.load(structure_path)
+
+ # 2. Extract Text
+ logger.info("Extracting text segments...")
+ extractor = FineGrainedExtractor()
+ entries = extractor.extract(structure)
+
+ # 3. Update Manifest
+ logger.info(f"Updating manifest: {manifest_path}")
+ manager = ManifestManager(manifest_path)
+ manager.load()
+ manager.add_entries(entries)
+ manager.save()
+
+ logger.info("Preprocessing complete.")
+
+ except EpubTranslatorError as e:
+ logger.error(f"Preprocessing failed: {e}")
+ sys.exit(1)
+ except Exception as e:
+ logger.critical(f"Unexpected error: {e}")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 1: Preprocessing (Clean + Extract)")
+ parser.add_argument("input_epub", help="Path to input EPUB")
+ parser.add_argument("--force", action="store_true", help="Force re-clean")
+
+ args = parser.parse_args()
+ run_preprocess(args)
diff --git a/archive/v0.12/pipeline/02_translate.py b/archive/v0.12/pipeline/02_translate.py
new file mode 100644
index 0000000..a49a49a
--- /dev/null
+++ b/archive/v0.12/pipeline/02_translate.py
@@ -0,0 +1,111 @@
+import argparse
+import sys
+import os
+import asyncio
+from pathlib import Path
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.translation.translator_engine import Translator
+from src.translation.llm_client import LLMClient
+from src.translation.manifest_manager import ManifestManager
+from src.preprocessing.profiler import BookProfiler
+from src.common.utils import setup_logger
+from src.common.exceptions import EpubTranslatorError
+from src.common.config import load_global_config
+from src.common.paths import get_work_dirs
+
+logger = setup_logger("pipeline_translate")
+
+async def run_translate(args):
+ # Resolve paths
+ input_path = None
+ if args.input_epub:
+ input_path = Path(args.input_epub)
+ elif args.book_name:
+ # Try to infer from work dir if book_name provided (legacy support)
+ # But paths.py needs input_path to determine work dir.
+ # So we really need input_epub for get_work_dirs.
+ # But if the user only provides book_name, we might be in trouble with get_work_dirs logic
+ # which relies on input_path.stem.
+ # Let's check get_work_dirs again.
+ pass
+
+ # Actually get_work_dirs relies on input_path.stem.
+ # If the user gives only --book-name, we can't easily construct input_path
+ # unless we fake it or change get_work_dirs.
+ # However, pipeline instructions say input_epub is required for 02_translate.
+ # checking args... 02_translate has --input-epub AND --book-name.
+
+ if args.input_epub:
+ input_path = Path(args.input_epub)
+ else:
+ logger.error("Must provide --input-epub")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ manifest_path = work["manifest"]
+
+ if not manifest_path.exists():
+ logger.error(f"Manifest not found: {manifest_path}. Run Step 1 first.")
+ sys.exit(1)
+
+ # Load Config
+ config = load_global_config()
+ llm_conf = config.get("llm", {})
+ trans_conf = config.get("translation", {})
+
+ api_key = llm_conf.get("api_key")
+ if not api_key:
+ logger.error("OPENAI_API_KEY not found in env or config.")
+ sys.exit(1)
+
+ try:
+ # Load Manifest
+ manager = ManifestManager(manifest_path)
+ manager.load()
+
+ # Init components
+ # Allow CLI args to override config if needed (not implemented yet, taking config partial priority)
+ llm = LLMClient(
+ api_key=api_key,
+ base_url=llm_conf.get("base_url"),
+ model=args.model if args.model else llm_conf.get("model", "gpt-3.5-turbo"),
+ requests_per_minute=llm_conf.get("requests_per_minute", 60),
+
+ concurrent_requests=llm_conf.get("concurrent_requests", 5),
+ chunk_dir=work["chunks"]
+ )
+
+ # Profile
+ profiler = BookProfiler(llm)
+ profile = await profiler.analyze(manager.entries)
+ logger.info(f"Book Profile: {profile.title} ({profile.genre})")
+
+ # Translate
+ target_chunk_size = trans_conf.get("chunk_size", 4000)
+ concurrent_reqs = llm_conf.get("concurrent_requests", 5)
+ translator = Translator(llm, chunk_size=target_chunk_size, max_concurrent=concurrent_reqs)
+ await translator.translate(manager.entries, profile)
+
+ # Save final state
+ manager.save()
+ await llm.close()
+
+ logger.info("Translation complete.")
+
+ except EpubTranslatorError as e:
+ logger.error(f"Translation failed: {e}")
+ sys.exit(1)
+ except Exception as e:
+ logger.critical(f"Unexpected error: {e}")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 2: Translation")
+ parser.add_argument("--input-epub", help="Path to original EPUB (to derive book name)")
+ parser.add_argument("--book-name", help="Book name (folder name in work/)")
+ parser.add_argument("--model", default=None, help="LLM Model (overrides config)")
+
+ args = parser.parse_args()
+ asyncio.run(run_translate(args))
diff --git a/archive/v0.12/pipeline/03_assemble.py b/archive/v0.12/pipeline/03_assemble.py
new file mode 100644
index 0000000..a62e7d3
--- /dev/null
+++ b/archive/v0.12/pipeline/03_assemble.py
@@ -0,0 +1,106 @@
+import argparse
+import sys
+import os
+from pathlib import Path
+
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.assembly.backfiller import BackfillEngine
+from src.assembly.builder import BilingualBuilder
+from src.translation.manifest_manager import ManifestManager
+from src.translation.llm_client import LLMClient
+from src.common.data_model import BookStructure
+from src.common.exceptions import EpubTranslatorError
+from src.common.utils import setup_logger, ensure_directory
+from src.common.paths import get_work_dirs
+from src.common.config import load_global_config
+from dotenv import load_dotenv
+
+logger = setup_logger("pipeline_assemble")
+
+async def run_assemble(args):
+ input_path = Path(args.input_epub)
+ if not input_path.exists():
+ logger.error(f"Input file not found: {input_path}")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ work_root = work["root"]
+ structure_path = work["structure"]
+ manifest_path = work["manifest"]
+
+ if not structure_path.exists() or not manifest_path.exists():
+ logger.error("Missing structure or manifest. Run Step 1.")
+ sys.exit(1)
+
+ output_dir = Path(args.output_dir)
+ ensure_directory(output_dir)
+
+ # Load Config for LLM (Optional for repair)
+ load_dotenv()
+ config = load_global_config()
+ llm_conf = config.get("llm", {})
+ api_key = llm_conf.get("api_key") or os.getenv("OPENAI_API_KEY") # Ensure env priority
+
+ llm_client = None
+ if api_key:
+ logger.info("Initializing LLM Client for placeholder repair...")
+ llm_client = LLMClient(
+ api_key=api_key,
+ base_url=llm_conf.get("base_url"),
+ model=llm_conf.get("model", "gpt-3.5-turbo"),
+ requests_per_minute=llm_conf.get("requests_per_minute", 60),
+ concurrent_requests=llm_conf.get("concurrent_requests", 5),
+ chunk_dir=work["chunks"]
+ )
+ else:
+ logger.warning("No API Key found. Placeholder repair will be disabled.")
+
+ try:
+ # Load Data
+ structure = BookStructure.load(structure_path)
+ manager = ManifestManager(manifest_path)
+ manager.load()
+
+ # Backfill
+ logger.info(f"Backfilling translations (Mode: {args.mode})...")
+ backfiller = BackfillEngine(llm_client=llm_client)
+ updated_structure = await backfiller.backfill(structure, manager.entries, mode=args.mode)
+
+ if llm_client:
+ await llm_client.close()
+
+ # Build
+ logger.info("Building EPUB...")
+ builder = BilingualBuilder(work_root, original_epub_path=input_path)
+
+ if args.mode == "bilingual":
+ output_filename = f"bilingual_{input_path.stem}.epub"
+ else:
+ output_filename = f"translated_{input_path.stem}.epub"
+
+ output_path = output_dir / output_filename
+
+ builder.build(updated_structure, output_path)
+ logger.info(f"Assembly complete. Output: {output_path}")
+
+ except EpubTranslatorError as e:
+ logger.error(f"Assembly failed: {e}")
+ sys.exit(1)
+ except Exception as e:
+ logger.critical(f"Unexpected error: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 3: Assembly (Backfill + Build)")
+ parser.add_argument("input_epub", help="Path to original EPUB")
+ parser.add_argument("--output-dir", default="output", help="Output directory")
+ parser.add_argument("--bilingual", action="store_true", help="Output bilingual version (default target only)")
+
+ args = parser.parse_args()
+ args.mode = "bilingual" if args.bilingual else "target_only"
+ import asyncio
+ asyncio.run(run_assemble(args))
diff --git a/archive/v0.12/pipeline/03_restore_format.py b/archive/v0.12/pipeline/03_restore_format.py
new file mode 100644
index 0000000..43b45b9
--- /dev/null
+++ b/archive/v0.12/pipeline/03_restore_format.py
@@ -0,0 +1,86 @@
+import argparse
+import sys
+import os
+import asyncio
+from pathlib import Path
+from dotenv import load_dotenv
+
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.common.utils import setup_logger, ensure_directory
+from src.common.paths import get_work_dirs
+from src.common.config import load_global_config
+from src.translation.manifest_manager import ManifestManager
+from src.translation.llm_client import LLMClient
+from src.assembly.restoration_engine import RestorationEngine
+
+logger = setup_logger("pipeline_restore")
+
+async def run_restore(args):
+ input_path = Path(args.input_epub)
+ if not input_path.exists():
+ logger.error(f"Input file not found: {input_path}")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ manifest_path = work["manifest"]
+
+ if not manifest_path.exists():
+ logger.error("Missing manifest. Run Step 1 & 2.")
+ sys.exit(1)
+
+ # Load Config
+ load_dotenv()
+ config = load_global_config()
+ llm_conf = config.get("llm", {})
+ api_key = llm_conf.get("api_key") or os.getenv("OPENAI_API_KEY")
+
+ llm_client = None
+ if api_key:
+ logger.info("Initializing LLM Client for repairs...")
+ llm_client = LLMClient(
+ api_key=api_key,
+ base_url=llm_conf.get("base_url"),
+ model=llm_conf.get("model", "gpt-3.5-turbo"),
+ requests_per_minute=llm_conf.get("requests_per_minute", 60),
+ concurrent_requests=llm_conf.get("concurrent_requests", 5),
+ chunk_dir=work["chunks"] # Reuse chunks dir for logging repairs
+ )
+ else:
+ logger.warning("No API Key. LLM Repair disabled.")
+
+ try:
+ # Load Manifest
+ manager = ManifestManager(manifest_path)
+ manager.load()
+ logger.info(f"Loaded {len(manager.entries)} entries.")
+
+ # Restore Phase
+ engine = RestorationEngine(llm_client)
+ logger.info("Starting format restoration (Spacing + Tags + Repair)...")
+ if args.force:
+ logger.info("Force mode enabled: Re-processing all entries.")
+
+ success_count = await engine.restore_entries(manager.entries, force=args.force)
+
+ # Save Result
+ manager.save()
+ logger.info(f"Restoration complete. {success_count}/{len(manager.entries)} fully validated.")
+
+ except Exception as e:
+ logger.critical(f"Restoration failed: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+ finally:
+ if llm_client:
+ await llm_client.close()
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 3: Restore Format (HTML Generation)")
+ parser.add_argument("input_epub", help="Path to original EPUB")
+ parser.add_argument("--force", action="store_true", help="Force re-restoration")
+
+ args = parser.parse_args()
+ asyncio.run(run_restore(args))
diff --git a/archive/v0.12/pipeline/04_build_epub.py b/archive/v0.12/pipeline/04_build_epub.py
new file mode 100644
index 0000000..461b64a
--- /dev/null
+++ b/archive/v0.12/pipeline/04_build_epub.py
@@ -0,0 +1,84 @@
+import argparse
+import sys
+import os
+import asyncio
+from pathlib import Path
+from dotenv import load_dotenv
+
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.assembly.backfiller import BackfillEngine
+from src.assembly.builder import BilingualBuilder
+from src.translation.manifest_manager import ManifestManager
+from src.common.data_model import BookStructure
+from src.common.exceptions import EpubTranslatorError
+from src.common.utils import setup_logger, ensure_directory
+from src.common.paths import get_work_dirs
+from src.common.config import load_global_config
+
+logger = setup_logger("pipeline_build")
+
+async def run_build(args):
+ input_path = Path(args.input_epub)
+ if not input_path.exists():
+ logger.error(f"Input file not found: {input_path}")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ work_root = work["root"]
+ structure_path = work["structure"]
+ manifest_path = work["manifest"]
+
+ if not structure_path.exists() or not manifest_path.exists():
+ logger.error("Missing structure or manifest. Run Step 1-3.")
+ sys.exit(1)
+
+ output_dir = Path(args.output_dir)
+ ensure_directory(output_dir)
+
+ try:
+ # Load Data
+ logger.info("Loading structure and manifest...")
+ structure = BookStructure.load(structure_path)
+ manager = ManifestManager(manifest_path)
+ manager.load()
+
+ # Backfill (Pure Injection)
+ logger.info(f"Injecting content (Mode: {args.mode})...")
+ backfiller = BackfillEngine() # No dependencies needed
+ updated_structure = await backfiller.backfill(structure, manager.entries, mode=args.mode)
+
+ # Build
+ logger.info("Building EPUB...")
+ builder = BilingualBuilder(work_root, original_epub_path=input_path)
+
+ if args.mode == "bilingual":
+ output_filename = f"bilingual_{input_path.stem}.epub"
+ else:
+ output_filename = f"translated_{input_path.stem}.epub"
+
+ output_path = output_dir / output_filename
+
+ builder.build(updated_structure, output_path)
+ logger.info(f"Build complete. Output: {output_path}")
+
+ except EpubTranslatorError as e:
+ logger.error(f"Build failed: {e}")
+ sys.exit(1)
+ except Exception as e:
+ logger.critical(f"Unexpected error: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 4: Build EPUB (Injection + Packaging)")
+ parser.add_argument("input_epub", help="Path to original EPUB")
+ parser.add_argument("--output-dir", default="output", help="Output directory")
+ parser.add_argument("--bilingual", action="store_true", help="Output bilingual version (default target only)")
+
+ args = parser.parse_args()
+ args.mode = "bilingual" if args.bilingual else "target_only"
+
+ asyncio.run(run_build(args))
diff --git a/archive/v0.12/requirements.txt b/archive/v0.12/requirements.txt
new file mode 100644
index 0000000..a3c8bff
--- /dev/null
+++ b/archive/v0.12/requirements.txt
@@ -0,0 +1,12 @@
+ebooklib>=0.19
+beautifulsoup4>=4.12.0
+lxml>=4.9.0
+openai>=1.0.0
+aiohttp>=3.9.0
+pydantic>=2.0.0
+loguru>=0.7.0
+rich>=13.0.0
+asyncio-throttle>=1.0.2
+tenacity>=8.0.0
+python-dotenv>=1.0.0
+PyYAML>=6.0
diff --git a/archive/v0.12/src/__init__.py b/archive/v0.12/src/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/archive/v0.12/src/assembly/backfiller.py b/archive/v0.12/src/assembly/backfiller.py
new file mode 100644
index 0000000..53217c2
--- /dev/null
+++ b/archive/v0.12/src/assembly/backfiller.py
@@ -0,0 +1,107 @@
+from pathlib import Path
+from typing import List, Dict, Optional
+from bs4 import BeautifulSoup, NavigableString
+
+from src.common.data_model import BookStructure, ManifestEntry
+from src.common.utils import setup_logger
+
+logger = setup_logger("backfill_engine")
+
+class BackfillEngine:
+ """
+ Applies translations back to the BookStructure.
+ Now simplified to only inject 'translated_html' or fallback to text.
+ No format restoration logic here.
+ """
+
+ def __init__(self):
+ # No more Restorer or LLM Client
+ pass
+
+ async 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
+ manifest_map: Dict[str, Dict[str, ManifestEntry]] = {}
+ for entry in manifest_entries:
+ if not entry.translated_text:
+ continue
+
+ if entry.file_path not in manifest_map:
+ manifest_map[entry.file_path] = {}
+ manifest_map[entry.file_path][entry.element_id] = entry
+
+ # Iterate resources
+ 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
+
+ # Determine content to inject
+ # Prefer translated_html (rich text), fallback to translated_text (plain text)
+ html_content = entry.translated_html
+ plain_text = entry.translated_text
+
+ # Create translated tag
+ new_tag = soup.new_tag(element.name)
+
+ if html_content:
+ # Parse HTML fragment
+ # Wrap in div to handle multiple top-level nodes
+ inner_soup = BeautifulSoup(f"{html_content}
", 'html.parser')
+ container = inner_soup.find('div')
+ if container:
+ for child in list(container.children):
+ new_tag.append(child)
+ else:
+ new_tag.string = plain_text
+ else:
+ # Fallback to plain text
+ new_tag.string = plain_text
+
+ # Copy attributes
+ # 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
+
+ # Injection Strategy
+ if mode == "bilingual":
+ element.insert_after(new_tag)
+ else:
+ element.replace_with(new_tag)
+
+ modified = True
+
+ if modified:
+ resource.content = str(soup)
+
+ return structure
diff --git a/archive/v0.12/src/assembly/builder.py b/archive/v0.12/src/assembly/builder.py
new file mode 100644
index 0000000..5a65f79
--- /dev/null
+++ b/archive/v0.12/src/assembly/builder.py
@@ -0,0 +1,289 @@
+import shutil
+import uuid
+from pathlib import Path
+from ebooklib import epub
+from src.common.data_model import BookStructure
+from src.common.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()
+
+ # Check if this is the cover image
+ if structure.metadata.cover_image_id == item_id:
+ logger.info(f"Setting cover image: {item_id}")
+ # Manually add cover metadata to avoid "set_cover" creating a duplicate item with hardcoded ID
+ # 1. Add item normally (as EpubImage)
+ item = epub.EpubImage(
+ uid=item_id,
+ file_name=resource.href,
+ media_type=resource.media_type,
+ content=content
+ )
+ book.add_item(item)
+ items_map[item_id] = item
+
+ # 2. Add metadata pointing to it
+ book.add_metadata(None, 'meta', item_id, {'name': 'cover', 'content': item_id})
+ continue
+
+ 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()
+
+ # Check for existence (exact or suffix overlap to handle EPUB/OEBPS prefixes)
+ is_duplicate = False
+ if orig_name in added_hrefs:
+ is_duplicate = True
+ else:
+ for added in added_hrefs:
+ if added.endswith(orig_name) or orig_name.endswith(added):
+ is_duplicate = True
+ break
+
+ if is_duplicate:
+ continue
+
+ # Skip NCX and NAV - we generate these
+ if 'ncx' in orig_name.lower() or orig_name.endswith('nav.xhtml'):
+ continue
+
+ # Double check if it's an image we likely already processed
+ if orig_item.media_type and orig_item.media_type.startswith("image/"):
+ # If we didn't match via filename, verify if we missed it or if it's truly new
+ # But suffix check usually catches it.
+ pass
+
+ # 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
+
diff --git a/archive/v0.12/src/assembly/format_restorer.py b/archive/v0.12/src/assembly/format_restorer.py
new file mode 100644
index 0000000..4e33557
--- /dev/null
+++ b/archive/v0.12/src/assembly/format_restorer.py
@@ -0,0 +1,72 @@
+import re
+from typing import Dict, Tuple, List, Optional
+from loguru import logger
+from src.common.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], context_id: str = None) -> 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
+ context_msg = f" [ID: ...{context_id[-6:]}]" if context_id else ""
+
+ missing_ids = expected_ids - found_ids
+ if missing_ids:
+ logger.warning(f"Restoration warning: missing placeholders {missing_ids}{context_msg}")
+ 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}{context_msg}")
+ 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)
diff --git a/archive/v0.12/src/assembly/restoration_engine.py b/archive/v0.12/src/assembly/restoration_engine.py
new file mode 100644
index 0000000..61c0af6
--- /dev/null
+++ b/archive/v0.12/src/assembly/restoration_engine.py
@@ -0,0 +1,106 @@
+import asyncio
+from typing import List, Optional
+from loguru import logger
+
+from src.common.data_model import ManifestEntry
+from src.assembly.format_restorer import FormatRestorer
+from src.translation.llm_client import LLMClient
+from src.common.utils import add_spacing
+
+class RestorationEngine:
+ """
+ Handles post-translation format restoration:
+ 1. Spacing optimization (Pangu)
+ 2. HTML tag restoration (FormatRestorer)
+ 3. LLM-based repair (if restoration fails)
+ """
+
+ def __init__(self, llm_client: Optional[LLMClient] = None):
+ self.restorer = FormatRestorer()
+ self.llm_client = llm_client
+
+ async def restore_entries(self, entries: List[ManifestEntry], force: bool = False) -> int:
+ """
+ Process a list of entries, updating their translated_html field.
+ Returns the number of successfully restored entries.
+ """
+ success_count = 0
+
+ for entry in entries:
+ if not entry.translated_text:
+ continue
+
+ # Idempotency check: Skip if already restored (unless forced)
+ if entry.translated_html and not force:
+ success_count += 1
+ continue
+
+ # 1. Spacing Fix
+ spaced_text = add_spacing(entry.translated_text)
+
+ # 2. Format Restoration
+ restored_html, success = self.restorer.restore(
+ spaced_text,
+ entry.placeholders,
+ context_id=entry.entry_id
+ )
+
+ # 3. LLM Repair (if needed and available)
+ if not success and self.llm_client:
+ logger.debug(f"Attempting LLM repair for {entry.entry_id}...")
+ repaired_text = await self._repair_placeholders(entry, spaced_text)
+ if repaired_text:
+ # Retry with repaired text
+ repaired_html, repaired_success = self.restorer.restore(
+ repaired_text,
+ entry.placeholders,
+ context_id=f"{entry.entry_id}-REPAIR"
+ )
+ if repaired_success:
+ logger.info(f"LLM Repair successful for {entry.entry_id}")
+ restored_html = repaired_html
+ # We do NOT update translated_text here to preserve original LLM output?
+ # Actually user might want the spaced and repaired text as the "text".
+ # But let's keep translated_text as raw-ish, and translated_html as final.
+ # Update: To ensure consistency, maybe we should update translated_text?
+ # The implementation plan says "Save result to ManifestEntry.translated_html".
+ # It implicitly leaves translated_text alone or updates it?
+ # Let's keep translated_text as is (except maybe spacing? no, keep it raw).
+ # But wait, if we repair key text, next time we run, we might want to use the repaired text?
+ # For now, only populate translated_html.
+ success = True
+ else:
+ logger.warning(f"LLM Repair failed validation for {entry.entry_id}")
+
+ # 4. Save Result
+ # Even if validation failed, we often get a "best effort" restored_html from restorer (fallback).
+ # The restorer usually returns *something*.
+ # If completely failed (e.g. mismatch), restorer might return raw text or partial?
+ # FormatRestorer.restore returns (restored_str, success_bool).
+ # Even if success=False, restored_str is produced (often just stripping unused placeholders or keeping them raw).
+
+ entry.translated_html = restored_html
+ if success:
+ success_count += 1
+
+ return success_count
+
+ async def _repair_placeholders(self, entry: ManifestEntry, current_text: str) -> Optional[str]:
+ """Ask LLM to fix placeholders."""
+ try:
+ system_prompt = "You are a translation repair assistant."
+ user_prompt = f"""
+The following translation has incorrect placeholders.
+Please fix the placeholders in the Translated text so they match the Original text structure EXACTLY.
+Do NOT change the Chinese translation content, only fix the φXφ tags.
+
+Original: {entry.original_text}
+Translated (Broken): {current_text}
+
+Output ONLY the fixed Translated text.
+"""
+ repaired = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
+ return repaired.strip()
+ except Exception as e:
+ logger.error(f"LLM Repair error: {e}")
+ return None
diff --git a/archive/v0.12/src/common/config.py b/archive/v0.12/src/common/config.py
new file mode 100644
index 0000000..d843d95
--- /dev/null
+++ b/archive/v0.12/src/common/config.py
@@ -0,0 +1,71 @@
+import os
+import yaml
+from pathlib import Path
+from typing import Dict, Any
+from dotenv import load_dotenv
+
+from src.common.utils import setup_logger
+
+logger = setup_logger("config_loader")
+
+class ConfigLoader:
+ def __init__(self, config_path: str = "config/config.yaml"):
+ # Resolve absolute path relative to project root if needed,
+ # but usually running from root so relative is fine.
+ self.config_path = Path(config_path)
+ self.config = self._load_defaults()
+
+ def _load_defaults(self) -> Dict[str, Any]:
+ return {
+ "llm": {
+ "model": "gpt-3.5-turbo",
+ "base_url": "https://api.openai.com/v1",
+ "timeout": 60,
+ "requests_per_minute": 60,
+ "concurrent_requests": 5
+ },
+ "translation": {
+ "chunk_size": 4000
+ }
+ }
+
+ def load_config(self) -> Dict[str, Any]:
+ # 1. Load YAML
+ if self.config_path.exists():
+ try:
+ with open(self.config_path, 'r', encoding='utf-8') as f:
+ file_config = yaml.safe_load(f)
+ if file_config:
+ self._deep_update(self.config, file_config)
+ logger.info(f"Loaded config from {self.config_path}")
+ except Exception as e:
+ logger.error(f"Failed to load config file: {e}")
+ else:
+ logger.warning(f"Config file not found at {self.config_path}, using defaults")
+
+ # 2. Env overrides (Priority: Env > Config File > Defaults)
+ load_dotenv()
+
+ # API Key is mandatory from Env (security best practice)
+ api_key = os.getenv("OPENAI_API_KEY")
+ if api_key:
+ self.config["llm"]["api_key"] = api_key
+
+ # Base URL override
+ base_url = os.getenv("OPENAI_BASE_URL")
+ if base_url:
+ self.config["llm"]["base_url"] = base_url
+
+ return self.config
+
+ def _deep_update(self, d, u):
+ for k, v in u.items():
+ if isinstance(v, dict):
+ d[k] = self._deep_update(d.get(k, {}), v)
+ else:
+ d[k] = v
+ return d
+
+def load_global_config(path: str = "config/config.yaml") -> Dict[str, Any]:
+ loader = ConfigLoader(path)
+ return loader.load_config()
diff --git a/archive/v0.12/src/common/data_model.py b/archive/v0.12/src/common/data_model.py
new file mode 100644
index 0000000..f0dd8b7
--- /dev/null
+++ b/archive/v0.12/src/common/data_model.py
@@ -0,0 +1,53 @@
+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
+ translated_html: Optional[str] = Field(None, description="Final HTML with restored tags and formatting")
+ context: Optional[str] = None
+
+class BookMetaData(BaseModel):
+ title: str = "Unknown Title"
+ author: str = "Unknown Author"
+ language: str = "en"
+ identifier: str = ""
+ cover_image_id: Optional[str] = None
+
+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 = ""
+
diff --git a/archive/v0.12/src/common/exceptions.py b/archive/v0.12/src/common/exceptions.py
new file mode 100644
index 0000000..2ca7b05
--- /dev/null
+++ b/archive/v0.12/src/common/exceptions.py
@@ -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
diff --git a/archive/v0.12/src/common/paths.py b/archive/v0.12/src/common/paths.py
new file mode 100644
index 0000000..2c223bf
--- /dev/null
+++ b/archive/v0.12/src/common/paths.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+def get_work_dirs(input_path: Path) -> dict:
+ """
+ Get unified work directory paths for a specific book.
+
+ Structure:
+ .work/
+ ├── {book_name}/
+ │ ├── book_structure.json
+ │ ├── manifest.json
+ │ ├── assets/
+ │ └── chunks/
+ """
+ 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",
+ }
diff --git a/archive/v0.12/src/common/utils.py b/archive/v0.12/src/common/utils.py
new file mode 100644
index 0000000..d024f70
--- /dev/null
+++ b/archive/v0.12/src/common/utils.py
@@ -0,0 +1,43 @@
+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)
+
+def add_spacing(text: str) -> str:
+ """
+ Add space between CJK and English/Number/Symbol characters.
+ Simplified version of pangu.js logic.
+ """
+ import re
+ if not text:
+ return text
+
+ # CJK followed by non-CJK
+ text = re.sub(r'([\u4e00-\u9fa5])([a-zA-Z0-9])', r'\1 \2', text)
+ # Non-CJK followed by CJK
+ text = re.sub(r'([a-zA-Z0-9])([\u4e00-\u9fa5])', r'\1 \2', text)
+
+ # Optional: Handle symbols like quote against CJK?
+ # For now, stick to user request: "中英文数字混排" (Chinese-English-Numbers)
+ return text
diff --git a/archive/v0.12/src/preprocessing/epub_cleaner.py b/archive/v0.12/src/preprocessing/epub_cleaner.py
new file mode 100644
index 0000000..ff428f0
--- /dev/null
+++ b/archive/v0.12/src/preprocessing/epub_cleaner.py
@@ -0,0 +1,184 @@
+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.common.data_model import BookStructure, BookMetaData, ResourceItem
+from src.common.exceptions import CleaningError
+from src.common.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
+
+ # Check if it is a cover
+ if media_type == ebooklib.ITEM_COVER:
+ metadata.cover_image_id = item_id
+ logger.info(f"Found cover image: {item_id} ({file_name})")
+
+ # 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'
diff --git a/archive/v0.12/src/preprocessing/format_extractor.py b/archive/v0.12/src/preprocessing/format_extractor.py
new file mode 100644
index 0000000..e7449ec
--- /dev/null
+++ b/archive/v0.12/src/preprocessing/format_extractor.py
@@ -0,0 +1,375 @@
+import re
+import html
+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):
+ # Unescape HTML entities first (e.g. & -> &)
+ s = html.unescape(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
diff --git a/archive/v0.12/src/preprocessing/profiler.py b/archive/v0.12/src/preprocessing/profiler.py
new file mode 100644
index 0000000..7beee6e
--- /dev/null
+++ b/archive/v0.12/src/preprocessing/profiler.py
@@ -0,0 +1,83 @@
+import json
+import random
+from typing import Dict, List
+from loguru import logger
+from src.translation.llm_client import LLMClient
+from src.translation.manifest_manager import ManifestManager
+from src.common.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]
+
+ # Join text
+ full_text = "\n\n".join(intro_text[:5] + body_text)
+
+ # Strip placeholders to stop Profiler from seeing "garbage"
+ # Matches φ1φ, φ/1φ, etc.
+ import re
+ clean_text = re.sub(r'φ.*?φ', '', full_text)
+
+ return clean_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")
diff --git a/archive/v0.12/src/preprocessing/text_extractor.py b/archive/v0.12/src/preprocessing/text_extractor.py
new file mode 100644
index 0000000..f2bd23d
--- /dev/null
+++ b/archive/v0.12/src/preprocessing/text_extractor.py
@@ -0,0 +1,132 @@
+import re
+from typing import List, Dict, Any, Optional
+from bs4 import BeautifulSoup
+from loguru import logger
+
+from src.common.data_model import BookStructure, ManifestEntry, BookProfile
+from src.preprocessing.format_extractor import FormatExtractor
+from src.common.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
diff --git a/archive/v0.12/src/translation/llm_client.py b/archive/v0.12/src/translation/llm_client.py
new file mode 100644
index 0000000..03d3352
--- /dev/null
+++ b/archive/v0.12/src/translation/llm_client.py
@@ -0,0 +1,231 @@
+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.common.data_model import ManifestEntry
+from src.common.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
+ # Handles both ASCII colon (:) and full-width colon (:)
+ match = re.match(r'^#(\d+)[::]\s*(.+)$', line)
+ if match:
+ short_id = f"#{match.group(1)}"
+ content = match.group(2).strip()
+
+ # Recursively strip repeated IDs (e.g. "#12: #12: Text")
+ while True:
+ sub_match = re.match(r'^#(\d+)[::]\s*(.+)$', content)
+ if sub_match:
+ # Check if the inner ID matches the outer ID, or just strip it anyway
+ # Usually LLM repeats the same ID.
+ content = sub_match.group(2).strip()
+ else:
+ break
+
+ if short_id in id_map:
+ full_id = id_map[short_id]
+ results[full_id] = content
+ 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()
diff --git a/archive/v0.12/src/translation/manifest_manager.py b/archive/v0.12/src/translation/manifest_manager.py
new file mode 100644
index 0000000..30e59fe
--- /dev/null
+++ b/archive/v0.12/src/translation/manifest_manager.py
@@ -0,0 +1,74 @@
+from pathlib import Path
+from typing import List, Dict, Optional
+import json
+from src.common.data_model import ManifestEntry
+from src.common.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}
diff --git a/archive/v0.12/src/translation/translator_engine.py b/archive/v0.12/src/translation/translator_engine.py
new file mode 100644
index 0000000..77015ca
--- /dev/null
+++ b/archive/v0.12/src/translation/translator_engine.py
@@ -0,0 +1,286 @@
+"""
+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.common.data_model import ManifestEntry, BookProfile
+from src.translation.llm_client import LLMClient
+from src.assembly.format_restorer import FormatRestorer
+from src.common.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,
+ context_id=entry.entry_id
+ )
+ 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
+
diff --git a/doc/OPERATION_MANUAL.md b/doc/OPERATION_MANUAL.md
index 692add5..65b9f69 100644
--- a/doc/OPERATION_MANUAL.md
+++ b/doc/OPERATION_MANUAL.md
@@ -34,16 +34,25 @@ python pipeline/02_translate.py --input-epub inputs/my_book.epub
```
Translates entries in `manifest.json`. ensuring `.env` has `OPENAI_API_KEY`.
-### Step 3: Assembly
-### Step 3: Assembly
+### Step 3: Restore Format (NEW)
```bash
-# Bilingual Output (Default: output/bilingual_my_book.epub)
-python pipeline/03_assemble.py inputs/my_book.epub --bilingual
-
-# Target Language Output (Default: output/translated_my_book.epub)
-python pipeline/03_assemble.py inputs/my_book.epub
+python pipeline/03_restore_format.py inputs/my_book.epub
```
-**Note**: The Assembly step now includes an **LLM-based Placeholder Repair** mechanism. If `format_restorer` detects broken placeholders in the translation, it will query the LLM (using your configured credentials) to attempt an automatic fix. Ensure your `OPENAI_API_KEY` is set if you want this feature enabled.
+**Optimizes and validates translations**:
+1. Applies "Pangu" spacing (inserts space between Chinese and English/Numbers).
+2. Restores HTML tags using placeholders.
+3. Attempts **LLM Auto-Repair** if validation fails.
+4. Saves result to `manifest.json` (`translated_html` field).
+
+### Step 4: Build EPUB
+```bash
+# Bilingual Output (Default)
+python pipeline/04_build_epub.py inputs/my_book.epub --bilingual
+
+# Target Language Output
+python pipeline/04_build_epub.py inputs/my_book.epub
+```
+**Pure assembly**: Injects the pre-validated `translated_html` into the EPUB structure. Fast and deterministic.
## Configuration
@@ -88,6 +97,15 @@ If you see `AuthenticationError` despite having the correct `base_url` in config
## Change Log
+### [2026-02-01] Pipeline Separation & Formatting
+* **Architecture**: Decoupled "Restoration" from "Assembly" into a 4-step pipeline.
+ * **New Step 3**: `03_restore_format.py` handles formatting, spacing, and repair. Saves to `translated_html`.
+ * **New Step 4**: `04_build_epub.py` handles pure EPUB generation.
+* **Data Model**: Added `translated_html` to `ManifestEntry` as the "Gold Master" formatted content.
+* **UX**: Added **Pangu Spacing** (Auto-spacing between CJK and ASCII) in Restoration step.
+* **Optimization**: `RestorationEngine` is now idempotent (skips processing if `translated_html` exists). Added `--force-restore` flag.
+* **Fix**: `main.py` updated to orchestrate the new 4-stage pipeline.
+
### [2026-01-31] Robustness & Repair
* **Feature**: Added **LLM-based Placeholder Repair** in Assembly stage. If placeholders mismatch, the system asks the LLM to fix the tags without changing text.
* **Fix**: Solved `FormatExtractor` "phantom placeholders" issue by correctly unescaping HTML entities during integrity checks.
diff --git a/input/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao).epub b/input/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao).epub
index 75c1bcc..24c70ed 100644
Binary files a/input/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao).epub and b/input/Empire of AI Dreams and Nightmares in Sam Altmans OpenAI (Karen Hao).epub differ
diff --git a/input/House of Huawei.epub b/input/House of Huawei.epub
new file mode 100644
index 0000000..879f2f6
Binary files /dev/null and b/input/House of Huawei.epub differ
diff --git a/main.py b/main.py
index 0b2779b..bce44c7 100644
--- a/main.py
+++ b/main.py
@@ -12,6 +12,7 @@ from src.preprocessing.text_extractor import FineGrainedExtractor
from src.translation.manifest_manager import ManifestManager
from src.translation.translator_engine import Translator
from src.translation.llm_client import LLMClient
+from src.assembly.restoration_engine import RestorationEngine
from src.assembly.backfiller import BackfillEngine
from src.assembly.builder import BilingualBuilder
from src.common.data_model import BookStructure
@@ -76,11 +77,9 @@ async def run_pipeline(args):
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)
-
+ # Initialize LLM Client (Centralized)
+ llm_client = None
+ if api_key:
llm_client = LLMClient(
api_key=api_key,
base_url=base_url,
@@ -89,6 +88,12 @@ async def run_pipeline(args):
concurrent_requests=llm_conf.get("concurrent_requests", 5),
chunk_dir=work["chunks"]
)
+
+ # 4. Translation
+ if not args.skip_translation:
+ if not llm_client:
+ logger.error("Cannot translate without API Key. Use --skip-translation to test pipeline.")
+ sys.exit(1)
# Profiling
profiler = BookProfiler(llm_client)
@@ -101,35 +106,25 @@ async def run_pipeline(args):
translator = Translator(llm_client, chunk_size=target_chunk_size, max_concurrent=concurrent_reqs)
await translator.translate(manifest_manager.entries, profile)
manifest_manager.save()
-
- # Don't close here, wait until after backfill
- # await llm_client.close()
- pass
else:
logger.info("Skipping translation step.")
- # 5. Backfill (now async + LLM repair enabled)
- # Reuse existing llm_client if available, otherwise create temporary one if needed?
- # In this flow, llm_client is created inside the 'if not args.skip_translation' block.
- # If skip_translation is True, llm_client is undefined.
+ # 5. Restoration (Format + Spacing + Repair)
+ logger.info("Restoring format & applying spacing...")
+ # RestorationEngine handles Pangu spacing, FormatRestorer, and LLM Repair
+ restorer = RestorationEngine(llm_client)
+ # Default to skipping if already done, unless forced
+ force_restore = getattr(args, 'force_restore', False)
+ restore_success = await restorer.restore_entries(manifest_manager.entries, force=force_restore)
+ manifest_manager.save() # Save translated_html
+ logger.info(f"Restoration complete. {restore_success} entries validated.")
- backfill_llm_client = None
- should_close_client = False
-
- if 'llm_client' in locals() and llm_client:
- backfill_llm_client = llm_client
- elif api_key and not args.skip_translation:
- # This case shouldn't happen because if not skip, we key llm_client above.
- # But if skip_translation is True, we might still want repair?
- # For now, let's only enable repair if translation occurred or if we explicitly create one.
- # User said: "LLM features may fail" if no key.
- pass
-
- # Initialization
- backfiller = BackfillEngine(llm_client=backfill_llm_client)
+ # 6. Backfill (Pure Injection)
+ logger.info("Injecting content into EPUB structure...")
+ backfiller = BackfillEngine() # Pure injection, no dependencies
updated_structure = await backfiller.backfill(structure, manifest_manager.entries, mode=args.mode)
- # 6. Assembly - Pass original EPUB for TOC preservation
+ # 7. Assembly - Pass original EPUB for TOC preservation
builder = BilingualBuilder(work["root"], original_epub_path=input_path)
if args.mode == "bilingual":
@@ -143,7 +138,7 @@ async def run_pipeline(args):
logger.info(f"Pipeline completed! Output: {created_epub}")
- if 'llm_client' in locals() and llm_client:
+ if llm_client:
await llm_client.close()
except EpubTranslatorError as e:
@@ -162,6 +157,7 @@ def main():
parser.add_argument("--model", default=None, help="LLM Model to use (overrides config)")
parser.add_argument("--bilingual", action="store_true", help="Output bilingual version (default is target language only)")
parser.add_argument("--skip-translation", action="store_true", help="Skip LLM translation (for testing)")
+ parser.add_argument("--force-restore", action="store_true", help="Force re-run format restoration/repair even if translated_html exists")
parser.add_argument("--force-clean", action="store_true", help="Force re-clean EPUB even if book_structure exists")
args = parser.parse_args()
diff --git a/output_test/translated_House of Huawei.epub b/output_test/translated_House of Huawei.epub
new file mode 100644
index 0000000..6619272
Binary files /dev/null and b/output_test/translated_House of Huawei.epub differ
diff --git a/pipeline/03_restore_format.py b/pipeline/03_restore_format.py
new file mode 100644
index 0000000..43b45b9
--- /dev/null
+++ b/pipeline/03_restore_format.py
@@ -0,0 +1,86 @@
+import argparse
+import sys
+import os
+import asyncio
+from pathlib import Path
+from dotenv import load_dotenv
+
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.common.utils import setup_logger, ensure_directory
+from src.common.paths import get_work_dirs
+from src.common.config import load_global_config
+from src.translation.manifest_manager import ManifestManager
+from src.translation.llm_client import LLMClient
+from src.assembly.restoration_engine import RestorationEngine
+
+logger = setup_logger("pipeline_restore")
+
+async def run_restore(args):
+ input_path = Path(args.input_epub)
+ if not input_path.exists():
+ logger.error(f"Input file not found: {input_path}")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ manifest_path = work["manifest"]
+
+ if not manifest_path.exists():
+ logger.error("Missing manifest. Run Step 1 & 2.")
+ sys.exit(1)
+
+ # Load Config
+ load_dotenv()
+ config = load_global_config()
+ llm_conf = config.get("llm", {})
+ api_key = llm_conf.get("api_key") or os.getenv("OPENAI_API_KEY")
+
+ llm_client = None
+ if api_key:
+ logger.info("Initializing LLM Client for repairs...")
+ llm_client = LLMClient(
+ api_key=api_key,
+ base_url=llm_conf.get("base_url"),
+ model=llm_conf.get("model", "gpt-3.5-turbo"),
+ requests_per_minute=llm_conf.get("requests_per_minute", 60),
+ concurrent_requests=llm_conf.get("concurrent_requests", 5),
+ chunk_dir=work["chunks"] # Reuse chunks dir for logging repairs
+ )
+ else:
+ logger.warning("No API Key. LLM Repair disabled.")
+
+ try:
+ # Load Manifest
+ manager = ManifestManager(manifest_path)
+ manager.load()
+ logger.info(f"Loaded {len(manager.entries)} entries.")
+
+ # Restore Phase
+ engine = RestorationEngine(llm_client)
+ logger.info("Starting format restoration (Spacing + Tags + Repair)...")
+ if args.force:
+ logger.info("Force mode enabled: Re-processing all entries.")
+
+ success_count = await engine.restore_entries(manager.entries, force=args.force)
+
+ # Save Result
+ manager.save()
+ logger.info(f"Restoration complete. {success_count}/{len(manager.entries)} fully validated.")
+
+ except Exception as e:
+ logger.critical(f"Restoration failed: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+ finally:
+ if llm_client:
+ await llm_client.close()
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 3: Restore Format (HTML Generation)")
+ parser.add_argument("input_epub", help="Path to original EPUB")
+ parser.add_argument("--force", action="store_true", help="Force re-restoration")
+
+ args = parser.parse_args()
+ asyncio.run(run_restore(args))
diff --git a/pipeline/04_build_epub.py b/pipeline/04_build_epub.py
new file mode 100644
index 0000000..461b64a
--- /dev/null
+++ b/pipeline/04_build_epub.py
@@ -0,0 +1,84 @@
+import argparse
+import sys
+import os
+import asyncio
+from pathlib import Path
+from dotenv import load_dotenv
+
+# Add project root to sys.path
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from src.assembly.backfiller import BackfillEngine
+from src.assembly.builder import BilingualBuilder
+from src.translation.manifest_manager import ManifestManager
+from src.common.data_model import BookStructure
+from src.common.exceptions import EpubTranslatorError
+from src.common.utils import setup_logger, ensure_directory
+from src.common.paths import get_work_dirs
+from src.common.config import load_global_config
+
+logger = setup_logger("pipeline_build")
+
+async def run_build(args):
+ input_path = Path(args.input_epub)
+ if not input_path.exists():
+ logger.error(f"Input file not found: {input_path}")
+ sys.exit(1)
+
+ work = get_work_dirs(input_path)
+ work_root = work["root"]
+ structure_path = work["structure"]
+ manifest_path = work["manifest"]
+
+ if not structure_path.exists() or not manifest_path.exists():
+ logger.error("Missing structure or manifest. Run Step 1-3.")
+ sys.exit(1)
+
+ output_dir = Path(args.output_dir)
+ ensure_directory(output_dir)
+
+ try:
+ # Load Data
+ logger.info("Loading structure and manifest...")
+ structure = BookStructure.load(structure_path)
+ manager = ManifestManager(manifest_path)
+ manager.load()
+
+ # Backfill (Pure Injection)
+ logger.info(f"Injecting content (Mode: {args.mode})...")
+ backfiller = BackfillEngine() # No dependencies needed
+ updated_structure = await backfiller.backfill(structure, manager.entries, mode=args.mode)
+
+ # Build
+ logger.info("Building EPUB...")
+ builder = BilingualBuilder(work_root, original_epub_path=input_path)
+
+ if args.mode == "bilingual":
+ output_filename = f"bilingual_{input_path.stem}.epub"
+ else:
+ output_filename = f"translated_{input_path.stem}.epub"
+
+ output_path = output_dir / output_filename
+
+ builder.build(updated_structure, output_path)
+ logger.info(f"Build complete. Output: {output_path}")
+
+ except EpubTranslatorError as e:
+ logger.error(f"Build failed: {e}")
+ sys.exit(1)
+ except Exception as e:
+ logger.critical(f"Unexpected error: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="Step 4: Build EPUB (Injection + Packaging)")
+ parser.add_argument("input_epub", help="Path to original EPUB")
+ parser.add_argument("--output-dir", default="output", help="Output directory")
+ parser.add_argument("--bilingual", action="store_true", help="Output bilingual version (default target only)")
+
+ args = parser.parse_args()
+ args.mode = "bilingual" if args.bilingual else "target_only"
+
+ asyncio.run(run_build(args))
diff --git a/src/assembly/backfiller.py b/src/assembly/backfiller.py
index d7ed950..53217c2 100644
--- a/src/assembly/backfiller.py
+++ b/src/assembly/backfiller.py
@@ -1,9 +1,8 @@
from pathlib import Path
from typing import List, Dict, Optional
-from bs4 import BeautifulSoup
+from bs4 import BeautifulSoup, NavigableString
from src.common.data_model import BookStructure, ManifestEntry
-from src.assembly.format_restorer import FormatRestorer
from src.common.utils import setup_logger
logger = setup_logger("backfill_engine")
@@ -11,11 +10,13 @@ logger = setup_logger("backfill_engine")
class BackfillEngine:
"""
Applies translations back to the BookStructure.
+ Now simplified to only inject 'translated_html' or fallback to text.
+ No format restoration logic here.
"""
- def __init__(self, llm_client=None):
- self.restorer = FormatRestorer()
- self.llm_client = llm_client
+ def __init__(self):
+ # No more Restorer or LLM Client
+ pass
async def backfill(self, structure: BookStructure, manifest_entries: List[ManifestEntry], mode: str = "bilingual") -> BookStructure:
"""
@@ -28,18 +29,17 @@ class BackfillEngine:
"""
logger.info(f"Backfilling with mode: {mode}")
- # Index manifest by file and element ID for faster lookup
- # Map: file_path -> element_id -> ManifestEntry
+ # Index manifest
manifest_map: Dict[str, Dict[str, ManifestEntry]] = {}
for entry in manifest_entries:
if not entry.translated_text:
- continue # Skip untranslated entries
+ continue
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
+ # Iterate resources
for item_id, resource in structure.resources.items():
if resource.media_type != "application/xhtml+xml" or resource.href not in manifest_map:
continue
@@ -59,50 +59,29 @@ class BackfillEngine:
logger.warning(f"Element {element_id} not found in {resource.href}")
continue
- # Restore formatting with improved logic
- restored_html, success = self.restorer.restore(
- entry.translated_text,
- entry.placeholders,
- context_id=element_id
- )
-
- # If restoration failed and LLM is available, try to repair
- if not success and self.llm_client:
- logger.info(f"Attempting LLM repair for {element_id}...")
- repaired_text = await self._repair_placeholders(entry)
- if repaired_text:
- # Retry restoration with repaired text
- repaired_html, repaired_success = self.restorer.restore(
- repaired_text,
- entry.placeholders,
- context_id=f"{element_id}-REPAIR"
- )
- if repaired_success:
- logger.info(f"LLM Repair successful for {element_id}")
- restored_html = repaired_html
- # Update entry to reflect repair (optional, but good for logs)
- entry.translated_text = repaired_text
- else:
- logger.warning(f"LLM Repair failed validation for {element_id}")
-
+ # Determine content to inject
+ # Prefer translated_html (rich text), fallback to translated_text (plain text)
+ html_content = entry.translated_html
+ plain_text = entry.translated_text
# Create translated tag
new_tag = soup.new_tag(element.name)
- # Parse restored HTML to get content nodes
- # Use html.parser but be careful about fragments
- # Wrap in div just to parse then extract children
- inner_soup = BeautifulSoup(f"{restored_html}
", 'html.parser')
- # inner_soup.div shouldn't be None if restored_html exists
- container = inner_soup.find('div')
- if container:
- for child in list(container.children):
- new_tag.append(child)
+ if html_content:
+ # Parse HTML fragment
+ # Wrap in div to handle multiple top-level nodes
+ inner_soup = BeautifulSoup(f"{html_content}
", 'html.parser')
+ container = inner_soup.find('div')
+ if container:
+ for child in list(container.children):
+ new_tag.append(child)
+ else:
+ new_tag.string = plain_text
else:
- # Fallback
- new_tag.string = entry.translated_text
+ # Fallback to plain text
+ new_tag.string = plain_text
-
+ # Copy attributes
# Copy classes and add 'translation'
classes = element.get('class', [])
if isinstance(classes, str):
@@ -114,6 +93,7 @@ class BackfillEngine:
if style:
new_tag['style'] = style
+ # Injection Strategy
if mode == "bilingual":
element.insert_after(new_tag)
else:
@@ -125,25 +105,3 @@ class BackfillEngine:
resource.content = str(soup)
return structure
-
- async def _repair_placeholders(self, entry: ManifestEntry) -> Optional[str]:
- """Ask LLM to fix placeholders in translated text."""
- try:
- system_prompt = "You are a translation repair assistant."
- user_prompt = f"""
-The following translation has incorrect placeholders.
-Please fix the placeholders in the Translated text so they match the Original text structure EXACTLY.
-Do NOT change the Chinese translation content, only fix the φXφ tags.
-
-Original: {entry.original_text}
-Translated (Broken): {entry.translated_text}
-
-Output ONLY the fixed Translated text.
-"""
- # Use raw completion as we don't have short ID context here
- # But LLMClient has raw_chat_completion
- repaired = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
- return repaired.strip()
- except Exception as e:
- logger.error(f"LLM Repair error: {e}")
- return None
diff --git a/src/assembly/restoration_engine.py b/src/assembly/restoration_engine.py
new file mode 100644
index 0000000..61c0af6
--- /dev/null
+++ b/src/assembly/restoration_engine.py
@@ -0,0 +1,106 @@
+import asyncio
+from typing import List, Optional
+from loguru import logger
+
+from src.common.data_model import ManifestEntry
+from src.assembly.format_restorer import FormatRestorer
+from src.translation.llm_client import LLMClient
+from src.common.utils import add_spacing
+
+class RestorationEngine:
+ """
+ Handles post-translation format restoration:
+ 1. Spacing optimization (Pangu)
+ 2. HTML tag restoration (FormatRestorer)
+ 3. LLM-based repair (if restoration fails)
+ """
+
+ def __init__(self, llm_client: Optional[LLMClient] = None):
+ self.restorer = FormatRestorer()
+ self.llm_client = llm_client
+
+ async def restore_entries(self, entries: List[ManifestEntry], force: bool = False) -> int:
+ """
+ Process a list of entries, updating their translated_html field.
+ Returns the number of successfully restored entries.
+ """
+ success_count = 0
+
+ for entry in entries:
+ if not entry.translated_text:
+ continue
+
+ # Idempotency check: Skip if already restored (unless forced)
+ if entry.translated_html and not force:
+ success_count += 1
+ continue
+
+ # 1. Spacing Fix
+ spaced_text = add_spacing(entry.translated_text)
+
+ # 2. Format Restoration
+ restored_html, success = self.restorer.restore(
+ spaced_text,
+ entry.placeholders,
+ context_id=entry.entry_id
+ )
+
+ # 3. LLM Repair (if needed and available)
+ if not success and self.llm_client:
+ logger.debug(f"Attempting LLM repair for {entry.entry_id}...")
+ repaired_text = await self._repair_placeholders(entry, spaced_text)
+ if repaired_text:
+ # Retry with repaired text
+ repaired_html, repaired_success = self.restorer.restore(
+ repaired_text,
+ entry.placeholders,
+ context_id=f"{entry.entry_id}-REPAIR"
+ )
+ if repaired_success:
+ logger.info(f"LLM Repair successful for {entry.entry_id}")
+ restored_html = repaired_html
+ # We do NOT update translated_text here to preserve original LLM output?
+ # Actually user might want the spaced and repaired text as the "text".
+ # But let's keep translated_text as raw-ish, and translated_html as final.
+ # Update: To ensure consistency, maybe we should update translated_text?
+ # The implementation plan says "Save result to ManifestEntry.translated_html".
+ # It implicitly leaves translated_text alone or updates it?
+ # Let's keep translated_text as is (except maybe spacing? no, keep it raw).
+ # But wait, if we repair key text, next time we run, we might want to use the repaired text?
+ # For now, only populate translated_html.
+ success = True
+ else:
+ logger.warning(f"LLM Repair failed validation for {entry.entry_id}")
+
+ # 4. Save Result
+ # Even if validation failed, we often get a "best effort" restored_html from restorer (fallback).
+ # The restorer usually returns *something*.
+ # If completely failed (e.g. mismatch), restorer might return raw text or partial?
+ # FormatRestorer.restore returns (restored_str, success_bool).
+ # Even if success=False, restored_str is produced (often just stripping unused placeholders or keeping them raw).
+
+ entry.translated_html = restored_html
+ if success:
+ success_count += 1
+
+ return success_count
+
+ async def _repair_placeholders(self, entry: ManifestEntry, current_text: str) -> Optional[str]:
+ """Ask LLM to fix placeholders."""
+ try:
+ system_prompt = "You are a translation repair assistant."
+ user_prompt = f"""
+The following translation has incorrect placeholders.
+Please fix the placeholders in the Translated text so they match the Original text structure EXACTLY.
+Do NOT change the Chinese translation content, only fix the φXφ tags.
+
+Original: {entry.original_text}
+Translated (Broken): {current_text}
+
+Output ONLY the fixed Translated text.
+"""
+ repaired = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
+ return repaired.strip()
+ except Exception as e:
+ logger.error(f"LLM Repair error: {e}")
+ return None
diff --git a/src/common/data_model.py b/src/common/data_model.py
index 4ae9033..f0dd8b7 100644
--- a/src/common/data_model.py
+++ b/src/common/data_model.py
@@ -10,6 +10,7 @@ class ManifestEntry(BaseModel):
original_text: str
placeholders: Dict[str, str] = Field(default_factory=dict)
translated_text: Optional[str] = None
+ translated_html: Optional[str] = Field(None, description="Final HTML with restored tags and formatting")
context: Optional[str] = None
class BookMetaData(BaseModel):
diff --git a/src/common/utils.py b/src/common/utils.py
index e1cd9b3..d024f70 100644
--- a/src/common/utils.py
+++ b/src/common/utils.py
@@ -23,3 +23,21 @@ def ensure_directory(path: Path):
"""Ensures a directory exists."""
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
+
+def add_spacing(text: str) -> str:
+ """
+ Add space between CJK and English/Number/Symbol characters.
+ Simplified version of pangu.js logic.
+ """
+ import re
+ if not text:
+ return text
+
+ # CJK followed by non-CJK
+ text = re.sub(r'([\u4e00-\u9fa5])([a-zA-Z0-9])', r'\1 \2', text)
+ # Non-CJK followed by CJK
+ text = re.sub(r'([a-zA-Z0-9])([\u4e00-\u9fa5])', r'\1 \2', text)
+
+ # Optional: Handle symbols like quote against CJK?
+ # For now, stick to user request: "中英文数字混排" (Chinese-English-Numbers)
+ return text