From b92a0a39c0b70f4ccb47f29913ebae593233e82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9F=D0=BE=D0=BB=D1=8F=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= Date: Mon, 3 Aug 2026 10:31:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=B0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B4=D0=B0:=20=D1=81=D0=BE=D0=B2=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=87=D0=B8=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=B1=D0=B7=D0=B0=D1=86=D0=B5=D0=B2=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=BE;=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D0=B5=D0=B3=D0=BE=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 +++++++++++++++++++++++++++--- SKILL.md | 24 +++++++++++++++++++----- scripts/test_translate.py | 17 +++++++++++++++++ scripts/translate.py | 24 +++++++++++++++++++++++- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f915356..0503869 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,22 @@ ebook-convert "Название.epub" "Название.azw3" ```bash git clone https://github.com/vetermanve/book_translator.git ~/Projects/book_translator -~/.venvs/pdf2epub/bin/pip install openai python-dotenv # openai нет в их requirements.txt -export DEEPSEEK_API_KEY=... # либо .env в рабочем каталоге, либо USE_OLLAMA +# их requirements.txt неполон: openai и pyyaml импортируются, но не перечислены +~/.venvs/pdf2epub/bin/pip install openai pyyaml python-dotenv + +umask 077 +mkdir -p out/tr +cat > out/tr/.env <<'EOF' +USE_LOCAL_MODEL=false +DEEPSEEK_API_KEY=<ключ> +EOF ``` +Без `pyyaml` каждый запрос падает внутри `_create_system_prompt` **до** обращения +к API, а сторонний скрипт молча подставляет `[UNTRANSLATED]` и рапортует +«API запросов: 0, ошибок: N». Ключ держать только в `.env` с правами `600`, +в командную строку и логи не выводить. + Сначала проверка, нужен ли перевод вообще (язык, объём; ничего не тратит): ```bash @@ -130,7 +142,19 @@ export DEEPSEEK_API_KEY=... # либо .env в рабочем каталог --repo ~/Projects/book_translator --workdir out/tr --workers 12 ``` -Прерванный перевод возобновляется: сторонний скрипт помнит готовые главы. +Прерванный перевод возобновляется: сторонний скрипт помнит готовые главы. Это +же свойство мешает при **неудачном** прогоне — упавшие главы он считает +готовыми, поэтому перед повторной попыткой: + +```bash +rm -rf out/tr/progress out/tr/context out/tr/translations +``` + +После сборки скрипт сам проверяет язык результата и завершается с ошибкой, +если текст остался на языке оригинала или содержит `[UNTRANSLATED]` — +совпадения числа абзацев мало, при сбое API сторонний скрипт подставляет +оригинал. Файл, не прошедший проверку, упаковывать нельзя. + Дальше `out/book.ru.html` упаковывается тем же `ebook-convert` с `--language=ru`. diff --git a/SKILL.md b/SKILL.md index b6d6fcd..e7423e5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -111,10 +111,16 @@ is the bridge: it writes the repo's input format, shells out to It reports block/chapter/character counts and the detected source language. `--force` overrides the refusal. -2. **Set up the external repo once** (a plain clone; never edit it), and its - credentials — `DEEPSEEK_API_KEY` in the environment or a `.env` in the - working directory, or `USE_OLLAMA` for a local model. Its `requirements.txt` - omits `openai`, which `deepseek_translator.py` imports — install it too. +2. **Set up the external repo once** (a plain clone; never edit it) and its + credentials — a `.env` in the working directory with `USE_LOCAL_MODEL=false` + and `DEEPSEEK_API_KEY=…`, or `USE_LOCAL_MODEL=true` plus `OLLAMA_MODEL` for a + local model. **Its `requirements.txt` is incomplete** — `pip install openai + pyyaml python-dotenv` as well. Both `openai` and `pyyaml` are imported at + runtime and missing from that file; without `pyyaml` every single request + dies inside `_create_system_prompt` *before* reaching the API, and the tool + reports "API запросов: 0, ошибок: N" while writing `[UNTRANSLATED]` stubs. + Write the `.env` under `umask 077`, and never echo the key into logs or + command output. 3. **Translate:** `python scripts/translate.py book.html book.ru.html --repo --workdir --workers 12` @@ -126,7 +132,15 @@ is the bridge: it writes the repo's input format, shells out to потеряна" counts paragraphs where the model mangled the inline-tag markers and the italics were dropped rather than corrupted. Both are expected to be near zero — a large number means the translator misbehaved, not that the - bridge is broken. + bridge is broken. The script then checks the output's actual language and + exits non-zero if the text is still the source language or contains + `[UNTRANSLATED]` stubs — **matching paragraph counts do not prove anything + was translated**, since the external tool substitutes the original on API + failure. Do not pack a file that failed this check. + **Re-running after a failure needs the state cleared:** the external repo's + `progress/` directory marks those chapters complete and will skip them. + Delete `/progress`, `/context`, and + `/translations` before the retry. 5. Pack `book.ru.html` with `--language=ru` and translated `--title`/`--authors`. How formatting survives a translator that only speaks plain text: inline `` diff --git a/scripts/test_translate.py b/scripts/test_translate.py index 11df63f..28d811e 100644 --- a/scripts/test_translate.py +++ b/scripts/test_translate.py @@ -71,6 +71,21 @@ def test_split_and_rebuild(tmp: Path): assert result.count("

") == 2 +def test_verify_output_catches_untranslated(tmp: Path): + """Регресс: первый боевой прогон отрапортовал успех на непереведённом файле.""" + bad = tmp / "bad.html" + bad.write_text("

[UNTRANSLATED] The build is broken.

\n" * 30, encoding="utf-8") + assert not t.verify_output(bad, "ru"), "заглушки должны заваливать проверку" + + english = tmp / "en.html" + english.write_text("

The build is broken again today.

\n" * 40, encoding="utf-8") + assert not t.verify_output(english, "ru"), "английский результат должен заваливать проверку" + + good = tmp / "ru.html" + good.write_text("

Сборка снова сломалась сегодня утром.

\n" * 40, encoding="utf-8") + assert t.verify_output(good, "ru") + + if __name__ == "__main__": import tempfile @@ -79,4 +94,6 @@ if __name__ == "__main__": test_language_detection() with tempfile.TemporaryDirectory() as d: test_split_and_rebuild(Path(d)) + with tempfile.TemporaryDirectory() as d: + test_verify_output_catches_untranslated(Path(d)) print("OK") diff --git a/scripts/translate.py b/scripts/translate.py index 9a89318..01db4ae 100644 --- a/scripts/translate.py +++ b/scripts/translate.py @@ -146,6 +146,24 @@ def rebuild(src, chapters, workdir, out): return missing == 0 +def verify_output(out, target): + """Совпадение числа абзацев ещё не значит, что перевод состоялся: внешний + скрипт при ошибке API молча подставляет оригинал. Проверяем язык результата. + """ + text = re.sub("<[^>]+>", "", out.read_text(encoding="utf-8")) + lang, share = detect_language(text) + stub = text.count("[UNTRANSLATED]") + print("результат: %s (кириллица %.0f%%), заглушек [UNTRANSLATED]: %d" + % (lang, share * 100, stub)) + if stub: + print("ВНИМАНИЕ: внешний переводчик вернул заглушки — проверь его логи и ключ API") + return False + if lang != target: + print("ВНИМАНИЕ: результат не на языке %s — перевода фактически не было" % target) + return False + return True + + def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("source", type=Path, help="XHTML от pdf2html.py") @@ -184,7 +202,11 @@ def main(): extracted = (args.workdir / "extracted").resolve() write_input(chapters, extracted) run_translator(args.repo.resolve(), args.workdir.resolve(), extracted, args.workers) - rebuild(args.source, chapters, args.workdir, args.output) + ok = rebuild(args.source, chapters, args.workdir, args.output) + if not verify_output(args.output, args.target): + sys.exit("перевод не состоялся — упаковывать этот файл нельзя") + if not ok: + print("часть блоков осталась на языке оригинала — см. счётчик выше") if __name__ == "__main__":