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

208 lines
9.3 KiB
Python
Raw Permalink 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
"""Ищет гендерно-зависимые обращения к читателю в главе Книги Монтана.
Использование:
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()