montana/Русский/Благаявесть/README/gender_check.py

208 lines
9.3 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Ищет гендерно-зависимые обращения к читателю в главе Книги Монтана.
Использование:
python3 gender_check.py "/путь/к/главе.md"
Возвращает код 0 если нарушений нет, 1 если есть.
Логика через pymorphy3:
1. VERB прошедшего времени мужского рода блокер, только если «ты» идёт в том же
предложении и в пределах 3 токенов до глагола (схема «ты успел подумать»).
Иначе глагол описывает другого субъекта (например, «Клод сформулировал»).
2. Полные прилагательные (ADJF) и полные причастия (PRTF) мужского рода блокер
только в именительном падеже и только в окне ±2 токенов от «ты»
(схема «Ты единственный», «другой ты»). В косвенных падежах они почти всегда
описывают существительное м.р. в предложении («в этот момент», «этому сну»), а
не читателя.
3. Краткие прилагательные (ADJS) и краткие причастия (PRTS) мужского рода блокер,
если «ты» есть в предложении (схема «ты готов», «ты уверен», «ты покинут»).
4. Местоимения (NPRO) не проверяются они согласуются с существительным,
не с обращением.
5. Существительные (NOUN) не проверяются свой род имеют по лексеме.
Омонимы (слова с разборами и masc, и femn) пропускаются даём шанс контексту.
"""
import re
import sys
from pathlib import Path
import pymorphy3
MORPH = pymorphy3.MorphAnalyzer()
READER = {"ты", "тебя", "тебе", "тобой", "тобою"}
WORD_RE = re.compile(r"[А-Яа-яЁё]+")
# Относительные местоимения, которые pymorphy3 классифицирует как ADJF,
# но функционально они описывают антецедент, а не читателя.
RELATIVE_PRONOUNS = {
"который", "которая", "которое", "которые",
"которого", "которой", "которых",
"которому", "которым", "которыми", "которому",
"котором", "которой",
"тот", "та", "то", "те", "того", "той", "тех",
"этот", "эта", "это", "эти", "этого", "этой", "этих",
"такой", "такая", "такое", "такие",
"всякий", "каждый", "любой", "иной", "сам",
"мой", "твой", "свой", "наш", "ваш", "его", "её", "их",
}
def analyse(word: str):
"""Возвращает словарь-справку по разборам слова.
Ключи POS, значения dict с флагами:
has_masc_nomn: есть разбор этого POS, в котором masc + nomn (подходящий для обращения)
has_masc_past: есть разбор VERB с past + masc
has_masc_short: для ADJS/PRTS есть разбор masc
has_verb_past_any: есть VERB past любого рода (для различения омонима с NOUN)
Разные формы одной лексемы имеют разные разборы; если хотя бы одна форма
«masc+nomn» или «masc+past» подходит форма потенциально гендерно-зависима."""
parses = MORPH.parse(word.lower())
found = {}
for p in parses:
pos = p.tag.POS
if pos is None:
continue
entry = found.setdefault(pos, dict(masc_nomn=False, masc_past=False, masc_short=False))
if "masc" in p.tag and "nomn" in p.tag:
entry["masc_nomn"] = True
if pos == "VERB" and "past" in p.tag and "masc" in p.tag:
entry["masc_past"] = True
if pos in ("ADJS", "PRTS") and "masc" in p.tag:
entry["masc_short"] = True
return found
def split_sentences(text: str):
cleaned = re.sub(r"[*_`#>|]+", " ", text)
out = []
for paragraph in re.split(r"\n\n+", cleaned):
for sent in re.split(r"(?<=[.!?])\s+", paragraph):
sent = sent.strip()
if sent:
out.append(sent)
return out
def scan_sentence(sent: str):
tokens = [(m.start(), m.group()) for m in WORD_RE.finditer(sent)]
lower = [t[1].lower() for t in tokens]
reader_idxs = [i for i, w in enumerate(lower) if w in READER]
if not reader_idxs:
return []
hits = []
for i, (_, word) in enumerate(tokens):
if lower[i] in READER:
continue
parses = analyse(word)
# 1. Краткие прил/причастия м.р. — ловим в любом месте предложения
reported = False
for pos_name in ("ADJS", "PRTS"):
info = parses.get(pos_name)
if info and info["masc_short"]:
hits.append((word, pos_name, "ты + краткое прил/прич м.р."))
reported = True
break
if reported:
continue
# 2. VERB past masc — только если «ты» в пределах 3 токенов назад
v = parses.get("VERB")
if v and v["masc_past"]:
near_before = any(0 < (i - ri) <= 3 for ri in reader_idxs)
if near_before:
hits.append((word, "VERB", "ты перед глаголом прошедшего м.р."))
continue
# 3. ADJF / PRTF масculine в именительном падеже + окно ±2 от «ты»
if lower[i] in RELATIVE_PRONOUNS:
continue
# Эвристика: если следующий токен — существительное м.р. в им.п.,
# прилагательное согласовано с ним, а не с «ты».
# Исключение: если между «ты» и прил. есть тире/двоеточие/"это" —
# именное сказуемое («Ты — единственный носитель»), проверяем.
next_is_masc_noun = False
if i + 1 < len(tokens):
next_word = tokens[i + 1][1]
for p in MORPH.parse(next_word.lower()):
if p.tag.POS == "NOUN" and "masc" in p.tag and "nomn" in p.tag:
next_is_masc_noun = True
break
predication_marker = False
for ri in reader_idxs:
if ri >= i:
continue
start = tokens[ri][0] + len(tokens[ri][1])
end = tokens[i][0]
between = sent[start:end]
if "" in between or "" in between or ":" in between:
predication_marker = True
break
if next_is_masc_noun and not predication_marker:
continue
for pos_name in ("ADJF", "PRTF"):
info = parses.get(pos_name)
if not info or not info["masc_nomn"]:
continue
# В именной группе после тире/двоеточия увеличиваем окно до 4 токенов,
# чтобы поймать перечисление: «Ты — другой, живой, дышащий».
window = 4 if predication_marker else 2
near = any(abs(i - ri) <= window for ri in reader_idxs)
if near:
hits.append((word, pos_name, "ты + прил/прич м.р. в именит. рядом"))
break
return hits
def find_line(text: str, sent: str) -> int:
marker = sent[:50].strip()
if not marker:
return 0
key = marker[:25]
for i, line in enumerate(text.split("\n"), 1):
if key in line:
return i
return 0
def check_file(path: Path):
text = path.read_text(encoding="utf-8")
hits = []
for sent in split_sentences(text):
for word, pos, reason in scan_sentence(sent):
line = find_line(text, sent)
hits.append((line, word, pos, reason, sent[:180]))
return hits
def main():
if len(sys.argv) != 2:
print("usage: gender_check.py <path.md>", file=sys.stderr)
sys.exit(2)
path = Path(sys.argv[1])
if not path.exists():
print(f"not found: {path}", file=sys.stderr)
sys.exit(2)
hits = check_file(path)
if not hits:
print(f"OK: {path.name} — гендерно-зависимых обращений к читателю не найдено")
sys.exit(0)
print(f"БЛОКЕРОВ: {len(hits)}")
prev = None
for line, word, pos, reason, ctx in hits:
if (line, ctx) != prev:
print(f"\nстрока {line}: {ctx}")
prev = (line, ctx)
print(f"{word!r} [{pos}] — {reason}")
sys.exit(1)
if __name__ == "__main__":
main()