textmachine/eval/role_topology/postproc.py

128 lines
8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""ДЕТЕРМИНИРОВАННЫЙ ПОСТ-ПРОЦЕССОР ЧЕРНОВИКА. $0 по ключам провайдеров.
Зачем. Оба слепых читателя независимо назвали главным источником непечатного текста НЕ перевод,
а механический слой: прямая речь в кавычках вместо тире, служебная преамбула модели, протёкшая
markdown-разметка, авторский текст под тире. Если дешёвая детерминированная правка поверх
черновика закрывает существенную часть разрыва «черновик против редактора» (+1.88), то второй
проход покупает меньше, чем кажется, и часть запланированных покупок уточняет цену того, что не
нужно. Проверка стоит только голосов судей — сам пост-процессор бесплатен.
Правила закрытые и проверяемые. Каждое чинит класс, названный читателями ЦИТАТОЙ, и ни одно не
трогает содержание перевода: пост-процессор не переставляет слова, не сливает абзацы и не правит
лексику. Он снимает ровно то, что является браком вывода, а не браком перевода.
⚠ Чего он НЕ делает и почему: слипшиеся абзацы не разделяются (границу хода детерминированно не
установить, а неверная разбивка хуже слипшейся), непереведённые остатки не переводятся (это
работа модели), калька не правится (это суждение).
Запуск: --selfcheck проверка правил на входах с известным ответом, $0
--emit породить арм F_post из черновиков, $0
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
# Служебная преамбула модели перед текстом. Поймана слепым чтением дословно:
# «Вот перевод фрагмента на русский язык в соответствии с вашими требованиями:».
_RE_PREAMBLE = re.compile(
r"^\s*(?:вот\s+)?(?:перевод|отредактированн\w+\s+текст|результат)[^\n:]{0,80}:\s*\n+",
re.I)
_RE_MD_HEAD = re.compile(r"^\s{0,3}#{1,6}\s+", re.M) # ### Раздел 17
_RE_MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S) # **Глава 19**
_RE_MD_ITAL = re.compile(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)", re.S)
_RE_HR = re.compile(r"^\s*(?:---+|===+|\*\s*\*\s*\*)\s*$", re.M)
# Реплика, целиком взятая в кавычки на отдельной строке: «…» или "…" в начале абзаца.
_RE_QUOTED_LINE = re.compile(r'^[ \t]*[«"„]\s*(.+?)\s*[»"“]([ \t]*[,.—-].*)?$', re.M)
_RE_MULTI_BLANK = re.compile(r"\n{3,}")
def strip_service(text: str) -> str:
"""Убрать служебную преамбулу и markdown-разметку, протёкшую в тело книги."""
t = _RE_PREAMBLE.sub("", text)
t = _RE_MD_HEAD.sub("", t)
t = _RE_MD_BOLD.sub(r"\1", t)
t = _RE_MD_ITAL.sub(r"\1", t)
t = _RE_HR.sub("", t)
return _RE_MULTI_BLANK.sub("\n\n", t).strip()
def dialogue_dash(text: str) -> str:
"""Реплику, стоящую отдельным абзацем в кавычках, оформить русским тире.
⚠ Правило намеренно узкое: трогается только строка, которая ЦЕЛИКОМ является закавыченной
репликой (возможно со словами автора после закрывающей кавычки). Кавычки ВНУТРИ абзаца не
трогаются — там они могут быть цитатой, названием или мыслью, и различить это детерминированно
нельзя. Узкое правило чинит меньше, но не портит.
"""
def repl(m: re.Match) -> str:
body, tail = m.group(1), (m.group(2) or "")
if len(body) < 2:
return m.group(0)
return f"{body}{tail}"
return _RE_QUOTED_LINE.sub(repl, text)
def process(text: str) -> str:
return dialogue_dash(strip_service(text))
CASES: list[tuple[str, str, str]] = [
("служебная преамбула",
"Вот перевод фрагмента на русский язык в соответствии с вашими требованиями:\n\nОн вышел.",
"Он вышел."),
("markdown-заголовок", "### Раздел семнадцатый\n\nОн вышел.", "Раздел семнадцатый\n\nОн вышел."),
("markdown-жирный", "**Глава 19: Цикада!**\n\nТекст.", "Глава 19: Цикада!\n\nТекст."),
("реплика в ёлочках", '«Время летит. Садись», — сказал он.', "— Время летит. Садись, — сказал он."),
("реплика в лапках", '"Спасибо", — ответил племянник.', "— Спасибо, — ответил племянник."),
("кавычки ВНУТРИ абзаца не трогаются",
'Он вспомнил «Тысячу и одну ночь» и усмехнулся.',
'Он вспомнил «Тысячу и одну ночь» и усмехнулся.'),
("уже тире — не трогается", "— Время летит. Садись, — сказал он.",
"— Время летит. Садись, — сказал он."),
("чистый текст не меняется", "Он вышел за дверь. Небо было серым.",
"Он вышел за дверь. Небо было серым."),
]
def selfcheck() -> int:
bad = 0
for name, src, want in CASES:
got = process(src)
ok = got == want
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f"\n получено: {got!r}\n ждали: {want!r}"))
print(f"\n{'ПРАВИЛА ЧИСТЫ' if not bad else f'ПРОВАЛОВ: {bad}'} ({len(CASES) - bad}/{len(CASES)})")
return bad
def emit() -> None:
"""Породить выходы арма F_post: пост-процессор поверх ТОГО ЖЕ черновика, что правят армы."""
import json # noqa: PLC0415
import bakeoff as BO # noqa: PLC0415
OUT = Path.home() / "books" / "role-topology"
changed = 0
for u in BO.units():
src = BO.text_of("F", u)
dst = process(src)
(OUT / f"bo2-F_post-{u['uid']}.json").write_text(
json.dumps(dict(tag=f"bo2-F_post-{u['uid']}", arm="F_post", uid=u["uid"],
model="$0-postproc", cost_usd=0.0, content=dst,
changed=dst != src, delta_chars=len(dst) - len(src)),
ensure_ascii=False), encoding="utf-8")
changed += dst != src
print(f"единиц {len(BO.units())} · пост-процессор изменил {changed}")
if __name__ == "__main__":
a = sys.argv[1:] or ["--selfcheck"]
if a[0] == "--selfcheck":
sys.exit(1 if selfcheck() else 0)
elif a[0] == "--emit":
emit()
else:
print(__doc__)