Проверка языка результата перевода: совпадения числа абзацев недостаточно; недостающие зависимости стороннего репозитория

This commit is contained in:
Илья Поляков
2026-08-03 10:31:35 +03:00
parent 9c7658df5e
commit b92a0a39c0
4 changed files with 86 additions and 9 deletions
+27 -3
View File
@@ -111,10 +111,22 @@ ebook-convert "Название.epub" "Название.azw3"
```bash ```bash
git clone https://github.com/vetermanve/book_translator.git ~/Projects/book_translator git clone https://github.com/vetermanve/book_translator.git ~/Projects/book_translator
~/.venvs/pdf2epub/bin/pip install openai python-dotenv # openai нет в их requirements.txt # их requirements.txt неполон: openai и pyyaml импортируются, но не перечислены
export DEEPSEEK_API_KEY=... # либо .env в рабочем каталоге, либо USE_OLLAMA ~/.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 ```bash
@@ -130,7 +142,19 @@ export DEEPSEEK_API_KEY=... # либо .env в рабочем каталог
--repo ~/Projects/book_translator --workdir out/tr --workers 12 --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` с Дальше `out/book.ru.html` упаковывается тем же `ebook-convert` с
`--language=ru`. `--language=ru`.
+19 -5
View File
@@ -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. It reports block/chapter/character counts and the detected source language.
`--force` overrides the refusal. `--force` overrides the refusal.
2. **Set up the external repo once** (a plain clone; never edit it), and its 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 credentials — a `.env` in the working directory with `USE_LOCAL_MODEL=false`
working directory, or `USE_OLLAMA` for a local model. Its `requirements.txt` and `DEEPSEEK_API_KEY=…`, or `USE_LOCAL_MODEL=true` plus `OLLAMA_MODEL` for a
omits `openai`, which `deepseek_translator.py` imports — install it too. 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:** 3. **Translate:**
`python scripts/translate.py book.html book.ru.html --repo <clone> --workdir <dir> --workers 12` `python scripts/translate.py book.html book.ru.html --repo <clone> --workdir <dir> --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 потеряна" counts paragraphs where the model mangled the inline-tag markers
and the italics were dropped rather than corrupted. Both are expected to be 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 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 `<workdir>/progress`, `<workdir>/context`, and
`<workdir>/translations` before the retry.
5. Pack `book.ru.html` with `--language=ru` and translated `--title`/`--authors`. 5. Pack `book.ru.html` with `--language=ru` and translated `--title`/`--authors`.
How formatting survives a translator that only speaks plain text: inline `<i>` How formatting survives a translator that only speaks plain text: inline `<i>`
+17
View File
@@ -71,6 +71,21 @@ def test_split_and_rebuild(tmp: Path):
assert result.count("<h1>") == 2 assert result.count("<h1>") == 2
def test_verify_output_catches_untranslated(tmp: Path):
"""Регресс: первый боевой прогон отрапортовал успех на непереведённом файле."""
bad = tmp / "bad.html"
bad.write_text("<p>[UNTRANSLATED] The build is broken.</p>\n" * 30, encoding="utf-8")
assert not t.verify_output(bad, "ru"), "заглушки должны заваливать проверку"
english = tmp / "en.html"
english.write_text("<p>The build is broken again today.</p>\n" * 40, encoding="utf-8")
assert not t.verify_output(english, "ru"), "английский результат должен заваливать проверку"
good = tmp / "ru.html"
good.write_text("<p>Сборка снова сломалась сегодня утром.</p>\n" * 40, encoding="utf-8")
assert t.verify_output(good, "ru")
if __name__ == "__main__": if __name__ == "__main__":
import tempfile import tempfile
@@ -79,4 +94,6 @@ if __name__ == "__main__":
test_language_detection() test_language_detection()
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
test_split_and_rebuild(Path(d)) test_split_and_rebuild(Path(d))
with tempfile.TemporaryDirectory() as d:
test_verify_output_catches_untranslated(Path(d))
print("OK") print("OK")
+23 -1
View File
@@ -146,6 +146,24 @@ def rebuild(src, chapters, workdir, out):
return missing == 0 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(): def main():
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("source", type=Path, help="XHTML от pdf2html.py") ap.add_argument("source", type=Path, help="XHTML от pdf2html.py")
@@ -184,7 +202,11 @@ def main():
extracted = (args.workdir / "extracted").resolve() extracted = (args.workdir / "extracted").resolve()
write_input(chapters, extracted) write_input(chapters, extracted)
run_translator(args.repo.resolve(), args.workdir.resolve(), extracted, args.workers) 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__": if __name__ == "__main__":