Skip to content

About

Multi-agent EPUB translation pipeline with review, formatting validation, and resumable caching

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

ebook-translator

CI Python 3.11+ License: MIT

A CLI tool that translates EPUB ebooks while preserving the original formatting, structure, and metadata. It runs a three-agent pipeline — Translator, Reviewer, Formatter — with each agent independently configurable to use any LLM provider (OpenAI-compatible, Anthropic, or Google).

                ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
EPUB ─ extract ─►  Translator │ ─► │   Reviewer  │ ─► │  Formatter  │ ─ rebuild ─► EPUB.<lang>.epub
                │ (per ¶ //)  │    │ (per chap.) │    │ (XHTML chk) │
                └─────────────┘    └─────────────┘    └─────────────┘
                       │                  │                  │
                ┌──────┴──────────────────┴──────────────────┴──────┐
                │   Provider layer · OpenAI / Anthropic / Google    │
                └───────────────────────────────────────────────────┘
                       │                                    │
                ┌──────┴──────┐                      ┌──────┴──────┐
                │  Cache (SQLite, by paragraph hash) │  Glossary   │
                └──────────────────────────────────────────────────┘

Features

  • Multi-provider: every agent picks its own provider, base URL, API key, and model. Mix local llama.cpp with cloud APIs.
  • Format-preserving: cover, table of contents, images, CSS, inline tags (<em>, <a href>, <strong>), and <dc:language> are kept intact.
  • Three-agent pipeline:
    • Translator runs in parallel, paragraph-by-paragraph, with the accumulated per-book glossary in context.
    • Reviewer re-reads each chapter end-to-end and returns delta revisions (only paragraphs that need changes) plus glossary additions for terminology consistency.
    • Formatter runs in parallel, validates inline tag structure (names + attributes), and only falls back to an LLM repair when validation fails.
  • Bootstrap pass: the first non-empty chapter is translated and reviewed sequentially before the parallel run, so the rest of the book translates against a populated glossary.
  • Resumable: a SQLite cache keyed by (book_sha256, paragraph_hash, langs, agent_role) skips work that already succeeded. Re-running after a crash is near-instant.
  • Cost estimation: estimate counts tokens locally and prints expected USD before any API call.
  • Calibre integration: list reads metadata.db from a Calibre library to show available EPUBs.

Install

The recommended install is via pipx (the binary lands in ~/.local/bin):

pipx install -e /path/to/ebook-translator
ebook-translator --help

Editable mode is convenient because pipx follows source changes automatically. For a stand-alone venv:

python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/ebook-translator --help

Python 3.11+. Tested on 3.14.

Privacy and data handling

Book text is sent directly to the provider configured for each agent. The project does not operate an intermediary service, but provider retention and training policies still apply. Use local OpenAI-compatible models for material that must not leave your machine, keep API keys in environment variables, and do not commit source books, translated books, caches, or local configuration.

See SECURITY.md for vulnerability reporting and disclosure guidance.

Configure

ebook-translator reads its configuration from one of:

  1. --config <path> (CLI flag, takes precedence)
  2. ./config.yaml (current working directory)
  3. ~/.config/ebook-translator/config.yaml

A complete reference is in src/ebook_translator/config.example.yaml (bundled with the package; run ebook-translator config init to copy it to your config path). The minimum:

agents:
  translator:
    provider: openai            # openai | anthropic | google
    api_key: ${ANTHROPIC_API_KEY}
    base_url: null              # set for OpenAI-compatible servers
    model: claude-sonnet-4-6
    max_tokens: 4096
    temperature: 0.3

  reviewer:
    provider: anthropic
    api_key: ${ANTHROPIC_API_KEY}
    model: claude-opus-4-7
    max_tokens: 32768
    temperature: 0.2

  formatter:
    provider: openai
    api_key: ${OPENAI_API_KEY}
    base_url: https://api.openai.com/v1
    model: gpt-4o-mini
    max_tokens: 8192
    temperature: 0.0

translation:
  source_language: auto
  target_language: pt
  concurrency: 8
  retries: 3
  retry_backoff_seconds: 2

storage:
  cache_db: ~/.local/share/ebook-translator/cache.sqlite
  glossary_db: ~/.local/share/ebook-translator/glossary.sqlite

${VAR} placeholders are expanded from the environment at load time.

Validate the resolved configuration before running:

ebook-translator config check

Provider examples

Anthropic

provider: anthropic
api_key: ${ANTHROPIC_API_KEY}
model: claude-sonnet-4-6        # or claude-opus-4-7, claude-haiku-4-5

OpenAI

provider: openai
api_key: ${OPENAI_API_KEY}
base_url: https://api.openai.com/v1
model: gpt-4o-mini              # or gpt-5, gpt-5-mini, o3-mini

Google Gemini

provider: google
api_key: ${GOOGLE_API_KEY}
model: gemini-2.5-flash         # or gemini-2.5-pro

OpenAI-compatible (local llama.cpp, Ollama, OpenRouter, Groq, vLLM, …)

Any server that speaks the chat-completions API works. Point base_url at it and pick a placeholder API key for SDKs that demand a non-empty value.

provider: openai
api_key: sk-no-key
base_url: http://localhost:4329/v1
model: hunyuan-mt-7b-translate

Hybrid (used in this repository's reference run)

agents:
  translator:                   # local, free, fast translation specialist
    provider: openai
    api_key: sk-no-key
    base_url: http://localhost:4329/v1
    model: hunyuan-mt-7b-translate
  reviewer:                     # cloud, large context for chapter coherence
    provider: openai
    api_key: ${QUANTMIND_API_KEY}
    base_url: https://api.quantmind.com.br/v1
    model: MiniMax-M2.7
  formatter:                    # cloud, called only on validation mismatch
    provider: openai
    api_key: ${QUANTMIND_API_KEY}
    base_url: https://api.quantmind.com.br/v1
    model: MiniMax-M2.7

Use

Estimate

estimate counts tokens with the same provider tokenizer that will be used for the real run, then multiplies by a built-in price table.

ebook-translator estimate ./book.epub --to pt

Translate

# Default: write next to the source as book.<lang>.epub
ebook-translator translate ./book.epub --to pt

# Specify source language explicitly (otherwise: auto)
ebook-translator translate ./book.epub --to pt --from en

# Override concurrency for this run
ebook-translator translate ./book.epub --to pt --concurrency 16

# Custom output path
ebook-translator translate ./book.epub --to pt --output /tmp/out.epub

# Custom config
ebook-translator translate ./book.epub --to pt --config ./custom.yaml

translate is idempotent: re-running on the same book reuses cached translations and reviews. To force a full re-run, clear the cache:

sqlite3 ~/.local/share/ebook-translator/cache.sqlite \
  "DELETE FROM translation_cache"

List Calibre library

ebook-translator list --library "/home/diogo/Biblioteca do calibre"

Reads metadata.db read-only and prints EPUBs with their author, title, and on-disk path.

Validate config

ebook-translator config check
ebook-translator config check --config ./alt.yaml

Prints the resolved agents (API keys redacted) and any validation errors. Exit code is non-zero on errors.

How it works

  1. Extract — ebooklib opens the EPUB; lxml walks each spine document. For every block-level element (<p>, <h1..h6>, <li>, <blockquote>, <td>, …) that contains no nested block, the inline content is captured as raw HTML, including <em>, <strong>, <a href>, etc. Each block gets a stable id {spine_index}:{ord_in_chapter}.

  2. Bootstrap (scout) — the first non-empty chapter is translated and reviewed sequentially, before the parallel pass. Goal: populate the glossary with proper nouns and recurring terminology so the rest of the book translates against consistent vocabulary.

  3. Translate (parallel, per paragraph) — each paragraph is hashed; cache hits skip the LLM. Otherwise the translator agent is called with a small system prompt plus a glossary block of relevant terms (substring match on the paragraph text). The prompt instructs the model to keep all HTML tags verbatim and translate only the visible text.

  4. Review (sequential, per chapter) — the reviewer agent receives every paragraph in the chapter side-by-side (source + draft translation) and returns a JSON object with delta revisions (only paragraphs that need changes) plus glossary additions. Paragraphs the reviewer omits keep their draft. Revisions overwrite the buffer; glossary entries are persisted per book.

  5. Format (parallel, per paragraph) — lxml parses both source and translated text; the formatter compares the structural signature (tag_local_name, sorted(attribs)) for every inline element. If signatures match and no stray markdown leaks (**bold**, __under__) are present, the translation is accepted as-is. Otherwise an LLM repair is requested with both originals.

  6. Reassemble — _replace_block_content reconstructs each block under its parent element's namespace; <html lang> and <dc:language> are updated to the target language; ebooklib writes the new EPUB.

File layout

src/ebook_translator/
├── cli.py                    # Typer commands
├── config.py                 # YAML loader + validation
├── orchestrator.py           # async pipeline (asyncio + tenacity)
├── epub_io.py                # ebooklib I/O
├── chunking.py               # XHTML → paragraphs (raw inline HTML)
├── cache.py                  # SQLite translation cache
├── glossary.py               # SQLite glossary
├── cost.py                   # token counting + price tables
├── agents/
│   ├── translator.py
│   ├── reviewer.py
│   └── formatter.py
├── providers/
│   ├── base.py               # Protocol + AgentConfig dataclass
│   ├── anthropic.py
│   ├── openai_compat.py
│   └── google.py
└── prompts/
    ├── translator.md
    ├── reviewer.md
    └── formatter.md

tests/
├── conftest.py               # mini.epub fixture
├── test_chunking.py
├── test_epub_io.py
├── test_providers.py
├── test_formatter.py
├── test_config.py
└── test_e2e_smoke.py         # full pipeline with mocked provider

Storage

File Purpose
~/.local/share/ebook-translator/cache.sqlite Per-paragraph translations (translator + reviewer roles, per source/target pair).
~/.local/share/ebook-translator/glossary.sqlite Per-book glossary built up by the reviewer.

Cache key columns: (book_sha256, paragraph_hash, source_lang, target_lang, agent_role). Two paragraphs with identical text share one row.

Local llama.cpp setup (optional)

For the local translator path used in this repo's example, the model is served by llama-server from llama.cpp.

A model-loader profile that fits an RTX 3090 + AMD Ryzen 9 9950X3D for ebook-translator's workload (16 parallel slots, prefix-cache reuse, threads pinned to physical cores) is included as guidance. Reference args:

--port 4329 --ngl 99 --flash-attn on --parallel 16
--ctx-size 32768 --batch-size 4096 --ubatch-size 1024
--cache-type-k q8_0 --cache-type-v q8_0 --cache-reuse 256
--cont-batching --kv-unified --metrics --mlock
--threads 16 --threads-batch 16

Then point the translator agent at http://localhost:4329/v1.

Cost in practice

Reference run: Driven to Distraction (E. M. Hallowell), 53 chapters, 2,194 paragraphs, English → Portuguese.

Stage Calls Notes
Translator (Hunyuan-MT-7B local) 2,194 Free, ~10 min on RTX 3090.
Reviewer (MiniMax-M2.7) 51 One per chapter (two skipped via cache after a previous crash).
Formatter (MiniMax-M2.7) 193 ~9 % of paragraphs needed structural repair.
Tokens (cloud only) ~1.2 M input 815k + output 385k.

End-to-end wall time on this machine: ~70 minutes (most of it formatter, which currently runs sequentially).

Limitations & known issues

  • Glossary scoping. Recurring proper nouns are tracked per book, not per series. Translating multiple books from one author won't share terminology.
  • Prices in cost.py are best-effort estimates. Verify on the provider's pricing page before relying on the number. The formatter cost in particular uses a 5 % LLM-repair rate heuristic; reference run hit 9 %.
  • No epubcheck step. Output is rebuilt with lxml + ebooklib and passes typical readers, but no formal IDPF validation is enforced.
  • Single-language output. No bilingual / side-by-side EPUB mode (yet).
  • Reviewer JSON parse failures fall back silently to drafts. When the reviewer model returns malformed JSON, the parser keeps the translator's draft for every paragraph in the chapter — quality degrades without an error. See "Troubleshooting" below.

Programmatic use

translate_book is the single async entry point used by the CLI. Embed it directly in your own tooling:

import asyncio
from pathlib import Path
from ebook_translator.config import load_config
from ebook_translator.orchestrator import translate_book

async def main():
    cfg = load_config(Path("./config.yaml"))
    out, stats = await translate_book(
        Path("./book.epub"),
        app=cfg,
        target_lang="pt",
        progress=lambda stage, cur, tot: print(f"{stage}: {cur}/{tot}"),
    )
    print(out, stats)

asyncio.run(main())

progress fires after each paragraph (translate / format) or chapter (review). See docs/ARCHITECTURE.md for the full data flow.

Configuration details

  • Resolution order. --config <path> > ./config.yaml > ~/.config/ebook-translator/config.yaml. The first hit wins.
  • Env vars. ${VAR} placeholders expand from the environment at load time. Unset variables silently expand to the empty string. validate_config flags an empty api_key only for the anthropic provider — cloud OpenAI/Google fail at first call instead, and local OpenAI-compatible servers may legitimately need no key.
  • extra field. Each agent block accepts an extra mapping for forward- looking keys. No provider currently consumes it.

Troubleshooting

  • OutputTruncatedError: empty content with finish_reason=length — reasoning models (e.g. MiniMax, OpenAI o-series) can exhaust max_tokens on chain-of-thought before producing any text. Raise max_tokens or disable reasoning in the model config.
  • Reviewer revisions silently equal drafts. The reviewer JSON parser is tolerant: on parse failure it returns the original drafts unchanged. If a chapter looks unimproved by the reviewer, suspect a malformed JSON response. Inspect stats.reviewer_calls and per-chapter token usage.
  • api_key empty after env interpolation. ${VAR} with VAR unset in your shell becomes "" silently. Re-run with the env var exported, or confirm with ebook-translator config check (api keys appear redacted but (unset) for empty).
  • Stale venv shebang after moving the project directory. .venv/bin/<tool> may break because it bakes in the absolute interpreter path. Fall back to PYTHONPATH=src .venv/bin/python -m pytest tests/ -q or recreate the venv.
  • Force a fresh translation. Clear the cache rows for that book:
    sqlite3 ~/.local/share/ebook-translator/cache.sqlite "DELETE FROM translation_cache"
    All other state (glossary, source EPUB) is untouched.
  • Reviewer JSON parsing tolerance. Code fences are stripped; the first {...} block is greedy-matched; missing paragraph ids fall back to the draft. Keep prompts simple — exotic Markdown wrappings around the JSON can cause the greedy match to capture the wrong span.

Development

# Run the test suite (offline; uses mocked providers + a synthetic mini.epub)
pytest tests/ -q

# Linter / type checker (optional)
pyright src/ tests/

The tests/conftest.py builds an in-memory mini EPUB; the smoke tests exercise the full orchestrator with a stub FakeProvider so no network calls happen.

License

MIT.

About

Multi-agent EPUB translation pipeline with review, formatting validation, and resumable caching

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages