ru-humanizer 3.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ """humanizer_metrics — детерминированные метрики живости текста.
2
+
3
+ Это грепабельная половина режима «Аудит» из SKILL.md: то, что считается
4
+ машиной, а не LLM. Используется и как локальный буст для Claude Code, и как
5
+ движок eval-харнеса (eval/run_eval.py), и как self-test самого скилла
6
+ (scripts/lint_skill.py).
7
+
8
+ Семантику (кальки, ирония, translationese, голос) тут не ловим — для этого
9
+ нужен сам скилл. Скрипты не работают в claude.ai web; скилл от них не зависит.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, replace
15
+
16
+ from .burstiness import RhythmStats, rhythm, rhythm_verdict
17
+ from .markers import (
18
+ MarkerHit,
19
+ marker_verdict,
20
+ scan_hard_bans,
21
+ scan_markers,
22
+ )
23
+ from .morphology import MorphStats, morph_stats, morph_verdict
24
+ from .structure import StructureStats, structure_stats, structure_verdict
25
+ from .score import ScoreResult, cleanliness_score
26
+
27
+ __all__ = [
28
+ "Report",
29
+ "analyze",
30
+ "RhythmStats",
31
+ "MorphStats",
32
+ "StructureStats",
33
+ "MarkerHit",
34
+ "ScoreResult",
35
+ "cleanliness_score",
36
+ "rhythm",
37
+ "morph_stats",
38
+ "structure_stats",
39
+ "scan_hard_bans",
40
+ "scan_markers",
41
+ "mask_foreign",
42
+ "strip_foreign",
43
+ "GAP",
44
+ "QUOTE_MAX_WORDS",
45
+ ]
46
+
47
+
48
+ @dataclass
49
+ class Report:
50
+ hard_bans: list[MarkerHit]
51
+ markers: list[MarkerHit]
52
+ rhythm: RhythmStats
53
+ morph: MorphStats
54
+ structure: StructureStats
55
+
56
+ @property
57
+ def hard_ban_count(self) -> int:
58
+ return sum(h.count for h in self.hard_bans)
59
+
60
+ @property
61
+ def marker_count(self) -> int:
62
+ return sum(h.count for h in self.markers)
63
+
64
+ def as_dict(self) -> dict:
65
+ return {
66
+ "hard_ban_count": self.hard_ban_count,
67
+ "hard_bans": [(h.marker, h.count) for h in self.hard_bans],
68
+ "marker_count": self.marker_count,
69
+ "markers": [(h.category, h.marker, h.count) for h in self.markers],
70
+ "rhythm": self.rhythm.as_dict(),
71
+ "morph": self.morph.as_dict(),
72
+ "structure": self.structure.as_dict(),
73
+ }
74
+
75
+
76
+ # --- Код и цитаты не текст автора ------------------------------------------
77
+ # Разбор границ Markdown живёт в markdown.py (один построчный проход по
78
+ # CommonMark и таблица тестов), здесь только две проекции текста.
79
+ from .markdown import GAP, QUOTE_MAX_WORDS, mask_foreign, strip_foreign # noqa: E402
80
+ from .facts import Facts, FactsDiff, diff_facts, extract_facts, facts_verdict # noqa: E402
81
+
82
+ mask_code_and_quotes = mask_foreign
83
+ strip_code = strip_foreign
84
+
85
+
86
+ def analyze(text: str) -> Report:
87
+ """Полный детерминированный прогон текста."""
88
+ lexical = mask_code_and_quotes(text)
89
+ prose = strip_code(text)
90
+ stats = rhythm(prose)
91
+ # Тире внутри короткой цитаты («Война — и мир») не типографика автора:
92
+ # считаем его по заглушенному тексту, ритм по прозе с цитатами.
93
+ stats = replace(stats, em_dash=lexical.count("—"))
94
+ return Report(
95
+ hard_bans=scan_hard_bans(lexical),
96
+ markers=scan_markers(lexical),
97
+ rhythm=stats,
98
+ morph=morph_stats(prose),
99
+ structure=structure_stats(prose),
100
+ )
@@ -0,0 +1,132 @@
1
+ """Ритм и типографика: то, что скилл велит измерять дисперсией, а не на глаз.
2
+
3
+ Закрывает пункты чеклиста «вариативность длины предложений — не средняя, а
4
+ ДИСПЕРСИЯ» и «ноль длинных тире». Burstiness тут — статистический прокси, не сам
5
+ детектор: высокий разброс длины предложений коррелирует с человеческим письмом
6
+ (SKILL.md, раздел «Что ловят детекторы»).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import statistics
13
+ from dataclasses import dataclass
14
+
15
+ from razdel import sentenize, tokenize
16
+
17
+
18
+ @dataclass
19
+ class RhythmStats:
20
+ sentences: int
21
+ words: int
22
+ mean_len: float # средняя длина предложения в словах
23
+ stdev_len: float # стандартное отклонение длины
24
+ cv_len: float # коэффициент вариации = stdev/mean (главный показатель)
25
+ min_len: int
26
+ max_len: int
27
+ short_share: float # доля предложений < 5 слов
28
+ long_share: float # доля предложений > 25 слов
29
+ em_dash: int # длинных тире «—»
30
+ ellipsis: int # многоточий
31
+ parentheses: int # скобочных ремарок
32
+ questions: int # вопросительных предложений
33
+ staccato_runs: int = 0 # цепочек из 3+ обрывков подряд (каталог #49)
34
+ staccato_max: int = 0 # длина самой длинной такой цепочки
35
+
36
+ def as_dict(self) -> dict:
37
+ return self.__dict__.copy()
38
+
39
+
40
+ _WORD_RE = re.compile(r"[А-Яа-яЁёA-Za-z0-9]")
41
+
42
+
43
+ def _word_count(sentence: str) -> int:
44
+ return sum(1 for t in tokenize(sentence) if _WORD_RE.search(t.text))
45
+
46
+
47
+ # Рваная медитативность (каталог #49): «Короткие. Точные. Отдельные.» Цепочка
48
+ # из трёх и более утверждений по 1-3 слова подряд. Не считаются реплики диалога
49
+ # (строка с тире или кавычки в начале), восклицания, вопросы, подводки с
50
+ # двоеточием, пункты списков и заголовки: там обрывки законны. Порог выбран по
51
+ # 400 постам Пикабу: доля обрывков в тексте срабатывала на 5-12% живых текстов
52
+ # и отвергнута, цепочка из трёх подряд даёт 4 из 400 (eval/OVERCORRECTION_CHECK.md).
53
+ STACCATO_MAX_WORDS = 3
54
+ STACCATO_MIN_RUN = 3
55
+ _DIALOG_RE = re.compile(r"^\s*[-–—«\"']")
56
+ _SKIP_LINE_RE = re.compile(r"^\s*(?:(?:[-*•]|\d+[.)])\s+|#)")
57
+
58
+
59
+ def staccato_runs(text: str) -> tuple[int, int]:
60
+ """(число цепочек, длина самой длинной). Цепочка не переходит через строку."""
61
+ runs: list[int] = []
62
+ for line in text.replace("\\", "").split("\n"):
63
+ if not line.strip() or _SKIP_LINE_RE.match(line):
64
+ continue
65
+ cur = 0
66
+ for s in sentenize(line):
67
+ st = s.text.strip()
68
+ n = _word_count(st)
69
+ if not n:
70
+ continue
71
+ if n <= STACCATO_MAX_WORDS and not _DIALOG_RE.match(st) and st[-1] not in "?!:;":
72
+ cur += 1
73
+ else:
74
+ if cur >= STACCATO_MIN_RUN:
75
+ runs.append(cur)
76
+ cur = 0
77
+ if cur >= STACCATO_MIN_RUN:
78
+ runs.append(cur)
79
+ return len(runs), max(runs, default=0)
80
+
81
+
82
+ def rhythm(text: str) -> RhythmStats:
83
+ sents = [s.text for s in sentenize(text)]
84
+ lengths = [_word_count(s) for s in sents]
85
+ lengths = [n for n in lengths if n > 0]
86
+ n = len(lengths)
87
+ total_words = sum(lengths)
88
+
89
+ runs, longest = staccato_runs(text)
90
+ mean = statistics.mean(lengths) if lengths else 0.0
91
+ stdev = statistics.pstdev(lengths) if n > 1 else 0.0
92
+ cv = (stdev / mean) if mean else 0.0
93
+
94
+ return RhythmStats(
95
+ sentences=n,
96
+ words=total_words,
97
+ mean_len=round(mean, 1),
98
+ stdev_len=round(stdev, 1),
99
+ cv_len=round(cv, 3),
100
+ min_len=min(lengths) if lengths else 0,
101
+ max_len=max(lengths) if lengths else 0,
102
+ short_share=round(sum(1 for x in lengths if x < 5) / n, 3) if n else 0.0,
103
+ long_share=round(sum(1 for x in lengths if x > 25) / n, 3) if n else 0.0,
104
+ em_dash=text.count("—"),
105
+ ellipsis=text.count("…") + len(re.findall(r"\.\.\.", text)),
106
+ parentheses=text.count("("),
107
+ questions=sum(1 for s in sents if s.rstrip().endswith("?")),
108
+ staccato_runs=runs,
109
+ staccato_max=longest,
110
+ )
111
+
112
+
113
+ # Эвристические пороги (откалиброваны под русский грубо, см. eval/RESULTS.md).
114
+ # cv_len < 0.35 — слишком ровный ритм, признак AI. Человеческий текст обычно > 0.45.
115
+ CV_AI_THRESHOLD = 0.35
116
+ CV_HUMAN_TARGET = 0.45
117
+
118
+
119
+ def rhythm_verdict(s: RhythmStats, dash_ok: bool = False) -> str:
120
+ """dash_ok=True для жанров, где длинное тире законно (научный,
121
+ юридический, художественный): тогда оно показывается фактом, не ⚠."""
122
+ if s.em_dash > 0 and dash_ok:
123
+ dash = f"тире: {s.em_dash} (законно для жанра)"
124
+ elif s.em_dash > 0:
125
+ dash = f"⚠ {s.em_dash} длинных тире (норма 0)"
126
+ else:
127
+ dash = "✓ тире чисто"
128
+ if s.cv_len < CV_AI_THRESHOLD:
129
+ rhythm_v = f"⚠ ровный ритм (CV={s.cv_len}, цель ≥{CV_HUMAN_TARGET})"
130
+ else:
131
+ rhythm_v = f"✓ ритм рваный (CV={s.cv_len})"
132
+ return f"{rhythm_v}; {dash}"
@@ -0,0 +1,193 @@
1
+ """Факт-замок как скрипт: что из фактов исходника дожило до результата.
2
+
3
+ Скилл обещает переносить числа, даты, имена, названия и ссылки без искажений и
4
+ не добавлять новых. Инструкция модели это одно, проверка другое: этот модуль
5
+ вынимает из двух текстов «факты» и сравнивает множества. Не семантика и не
6
+ фактчек: только сохранность того, что уже было, и появление того, чего не было.
7
+
8
+ Классы фактов:
9
+ - число: любой токен с цифрой (проценты, годы, суммы), нормализован до цифр;
10
+ - число словами: крупные числительные («сорок», «тысяча»), их не пересказывают;
11
+ - месяц: названия месяцев как замена дате;
12
+ - ссылка: URL и адреса вида domain.tld/path;
13
+ - код: содержимое инлайн-кода в бэктиках;
14
+ - имя: слово с заглавной не в начале предложения (с pymorphy3 точнее: теги
15
+ Name/Surn/Geox/Orgn/Patr и незнакомые словарю слова), плюс любая латиница с
16
+ заглавной («Excel», «GPT-4»).
17
+
18
+ Мелкие количества («два», «половина», «вдвое») факты мягкие: в русском они
19
+ идиоматичны («с одной стороны») и обычно пересказывают число исходника. Они
20
+ считаются, но не валят проверку.
21
+
22
+ Утверждения: слова-кванторы, которые звучат как пафос, а несут факт:
23
+ «первый в России», «единственный», «впервые», «рекордный», «до сих пор».
24
+ Срезать «первый в России сервис» до «сервис» значит исказить исходник, а
25
+ не оживить его. Потерянный квантор попадает в список потерь, появившийся
26
+ выносится отдельным предупреждением: кванторы идиоматичны («первый шаг»,
27
+ «самое время»), поэтому валить проверку автоматически нельзя, но глазами
28
+ такое место обязано проверить.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from dataclasses import dataclass, field
35
+
36
+ try:
37
+ import pymorphy3
38
+
39
+ _MORPH = pymorphy3.MorphAnalyzer()
40
+ except ImportError: # без словаря сравниваем по основе, грубее, но работает
41
+ _MORPH = None
42
+
43
+ PROPER_TAGS = {"Name", "Surn", "Geox", "Orgn", "Patr"}
44
+
45
+ MONTH_RE = re.compile(
46
+ r"\b(январ|феврал|март|апрел|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-я]*\b",
47
+ re.IGNORECASE,
48
+ )
49
+ URL_RE = re.compile(r"https?://[^\s)>\]»]+|\b[a-z0-9-]+\.(?:ru|com|org|net|io|tech|dev|ai|su|рф)(?:/[^\s)>\]»]*)?",
50
+ re.IGNORECASE)
51
+ CODE_RE = re.compile(r"`([^`\n]+)`")
52
+ TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\d\-]*|[А-Яа-яЁё][А-Яа-яЁё\-]*|\d[\d.,:/-]*")
53
+
54
+ SOFT_QUANTITIES = {
55
+ "один", "два", "две", "три", "оба", "обе", "пара",
56
+ "второй", "третий",
57
+ "вдвое", "втрое", "дважды", "трижды",
58
+ "половина", "треть", "четверть", "полтора",
59
+ }
60
+ HARD_NUMERALS = {
61
+ "четыре", "пять", "шесть", "семь", "восемь", "девять", "десять",
62
+ "одиннадцать", "двенадцать", "пятнадцать", "двадцать", "тридцать",
63
+ "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят",
64
+ "девяносто", "сто", "двести", "триста", "тысяча", "миллион", "миллиард",
65
+ "десяток", "сотня", "дюжина",
66
+ }
67
+ # Кванторы-утверждения: громкие слова, за которыми стоит проверяемый факт.
68
+ CLAIM_WORDS = {
69
+ "первый", "единственный", "впервые", "самый", "рекордный", "крупнейший",
70
+ "старейший", "никогда", "никто", "навсегда", "беспрецедентный",
71
+ }
72
+ CLAIM_PHRASE_RE = re.compile(r"\bдо сих пор\b|\bв мире\b|\bв россии\b", re.IGNORECASE)
73
+ # Одна сущность под разными именами не считается новым фактом.
74
+ ALIASES = {
75
+ "ai": ("искусственный", "интеллект", "ии", "нейросеть", "модель"),
76
+ "ии": ("искусственный", "интеллект", "ai", "нейросеть", "модель"),
77
+ "llm": ("модель", "нейросеть", "ai", "ии"),
78
+ }
79
+
80
+
81
+ @dataclass
82
+ class Facts:
83
+ hard: set[str] = field(default_factory=set) # "число:13", "имя:стэнфорд", "ссылка:…"
84
+ soft: set[str] = field(default_factory=set) # "два", "половина"
85
+ words: set[str] = field(default_factory=set) # все слова в нормальной форме, для алиасов
86
+ claims: set[str] = field(default_factory=set) # "утверждение:первый", "утверждение:до сих пор"
87
+
88
+
89
+ @dataclass
90
+ class FactsDiff:
91
+ lost: list[str] # были в исходнике, пропали
92
+ added: list[str] # появились в результате, в исходнике не было
93
+ kept: int # жёстких фактов исходника дожило
94
+ soft_added: list[str]
95
+ claims_added: list[str] = field(default_factory=list) # кванторы, которых в исходнике не было
96
+
97
+ @property
98
+ def ok(self) -> bool:
99
+ return not self.added
100
+
101
+ def as_dict(self) -> dict:
102
+ return {"ok": self.ok, "lost": self.lost, "added": self.added,
103
+ "kept": self.kept, "soft_added": self.soft_added,
104
+ "claims_added": self.claims_added}
105
+
106
+
107
+ def _norm(word: str) -> str:
108
+ low = word.lower().replace("ё", "е").strip("-")
109
+ if _MORPH is None:
110
+ return low[:5]
111
+ return _MORPH.parse(low)[0].normal_form.replace("ё", "е")
112
+
113
+
114
+ def _sentence_starts(text: str) -> set[int]:
115
+ starts = {0}
116
+ for m in re.finditer(r"[.!?…]\s+|^[>\-*]\s+|«|\n", text):
117
+ starts.add(m.end())
118
+ return starts
119
+
120
+
121
+ def _is_proper(word: str, at_start: bool) -> bool:
122
+ if not word[:1].isupper():
123
+ return False
124
+ if word.isascii():
125
+ return True
126
+ if _MORPH is None:
127
+ return not at_start
128
+ p = _MORPH.parse(word.lower())[0]
129
+ if PROPER_TAGS & set(p.tag.grammemes):
130
+ return True
131
+ if not p.is_known:
132
+ return True
133
+ return not at_start
134
+
135
+
136
+ def extract_facts(text: str) -> Facts:
137
+ f = Facts()
138
+ for m in URL_RE.finditer(text):
139
+ url = re.sub(r"^https?://(?:www\.)?", "", m.group(0).rstrip(".,;:").lower()).rstrip("/")
140
+ f.hard.add("ссылка:" + url)
141
+ for m in CODE_RE.finditer(text):
142
+ f.hard.add("код:" + m.group(1).strip())
143
+ body = CODE_RE.sub(" ", URL_RE.sub(" ", text))
144
+ for m in MONTH_RE.finditer(body):
145
+ f.hard.add("месяц:" + m.group(1).lower())
146
+ for m in CLAIM_PHRASE_RE.finditer(body):
147
+ f.claims.add("утверждение:" + m.group(0).lower().replace("ё", "е"))
148
+ starts = _sentence_starts(body)
149
+ for m in TOKEN_RE.finditer(body):
150
+ tok = m.group(0)
151
+ if tok[0].isdigit():
152
+ digits = re.sub(r"\D", "", tok)
153
+ if digits:
154
+ f.hard.add("число:" + (digits.lstrip("0") or "0"))
155
+ continue
156
+ norm = _norm(tok)
157
+ f.words.add(norm)
158
+ if norm in SOFT_QUANTITIES:
159
+ f.soft.add(norm)
160
+ elif norm in CLAIM_WORDS:
161
+ f.claims.add("утверждение:" + norm)
162
+ elif norm in HARD_NUMERALS:
163
+ f.hard.add("число словами:" + norm)
164
+ elif _is_proper(tok, any(abs(m.start() - s) <= 1 for s in starts)):
165
+ f.hard.add("имя:" + norm)
166
+ return f
167
+
168
+
169
+ def _alias_covered(fact: str, before_words: set[str]) -> bool:
170
+ if not fact.startswith("имя:"):
171
+ return False
172
+ name = fact[4:]
173
+ return any(alias in before_words for alias in ALIASES.get(name, ()))
174
+
175
+
176
+ def diff_facts(before: str, after: str) -> FactsDiff:
177
+ b, a = extract_facts(before), extract_facts(after)
178
+ lost = sorted(b.hard - a.hard) + sorted(b.claims - a.claims)
179
+ added = sorted(x for x in a.hard - b.hard if not _alias_covered(x, b.words))
180
+ return FactsDiff(lost=lost, added=added, kept=len(b.hard & a.hard),
181
+ soft_added=sorted(a.soft - b.soft),
182
+ claims_added=sorted(a.claims - b.claims))
183
+
184
+
185
+ def facts_verdict(d: FactsDiff) -> str:
186
+ if d.added:
187
+ return f"✗ новых фактов без источника: {len(d.added)} (выдумка хуже канцелярита)"
188
+ if d.lost:
189
+ return f"⚠ факты исходника на месте, но {len(d.lost)} потеряно: проверьте, намеренно ли"
190
+ if d.claims_added:
191
+ return (f"⚠ факты целы, но появилось утверждений без источника: {len(d.claims_added)} "
192
+ "(«впервые», «единственный» это факт, а не украшение)")
193
+ return f"✓ факт-замок цел: {d.kept} фактов исходника перенесено, новых нет"
@@ -0,0 +1,127 @@
1
+ """Границы чужого текста в Markdown: код, цитаты, короткие «ёлочки».
2
+
3
+ Сканер измеряет слова автора. Листинг кода, markdown-цитата через «>» и короткая
4
+ цитата в ёлочках это чужие слова, их баны и маркеры не в счёт. Два релиза
5
+ подряд границы чинились регулярками по одному примеру, и каждый раз соседний
6
+ вариант той же ошибки оставался (ограда внутри строки кода, закрытие оградой
7
+ длиннее открывающей, продолжение цитаты без «>»). Здесь один построчный разбор
8
+ состояния по правилам CommonMark и таблица тестов на варианты в
9
+ scripts/test_markers.py.
10
+
11
+ Две проекции одного текста:
12
+ - lexical: чужое заменено заглушкой GAP посимвольно, переводы строк на месте.
13
+ Смещения и номера строк совпадают с исходником, фразовые регексы не
14
+ склеивают слова через заглушку (она не буква).
15
+ - prose: чужое вырезано целиком. Для ритма, морфологии и структуры: заглушки
16
+ ломали бы длину предложений и подсчёт абзацев.
17
+
18
+ Правила:
19
+ - Ограда кода: 3+ обратных кавычек или тильд с отступом до 3 пробелов в начале
20
+ строки. Закрывает ограда того же символа НЕ КОРОЧЕ открывающей. Кавычки внутри
21
+ строки кода оградой не являются. Незакрытая ограда не маскируется вовсе:
22
+ для сканера ложное срабатывание дешевле спрятанного абзаца.
23
+ - Цитата: строка с «>» в начале (отступ до 3 пробелов). Ленивое продолжение:
24
+ следующие непустые строки без «>» остаются цитатой, пока не встретится
25
+ пустая строка, заголовок, пункт списка или ограда.
26
+ - Инлайн-код: серия обратных кавычек закрывается серией той же длины в
27
+ пределах строки (`x`, ``a`b``).
28
+ - Короткая цитата в ёлочках (до QUOTE_MAX_WORDS слов) заглушается только в
29
+ lexical: так цитируют слово или оборот. Длинная остаётся под сканером, это
30
+ прямая речь или пересказ, её маркеры на совести автора. В prose ёлочки не
31
+ трогаем, они внутри предложений автора.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+
38
+ GAP = "·"
39
+ QUOTE_MAX_WORDS = 12
40
+
41
+ _FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
42
+ _FENCE_CLOSE = re.compile(r"^ {0,3}(`{3,}|~{3,})\s*$")
43
+ _QUOTE_LINE = re.compile(r"^ {0,3}>")
44
+ # Строки, которые прерывают ленивое продолжение цитаты (CommonMark 5.1):
45
+ # заголовок, пункт списка, горизонтальная линия, ограда.
46
+ _BLOCK_START = re.compile(r"^ {0,3}(?:#{1,6}\s|[-*+]\s|\d{1,9}[.)]\s|(?:-{3,}|\*{3,}|_{3,})\s*$|`{3,}|~{3,})")
47
+ _INLINE_CODE = re.compile(r"(`+)(?!`)([^`\n]+?)\1(?!`)")
48
+ _QUOTE_MARKS = re.compile(r"«[^«»]*»")
49
+
50
+
51
+ def _blank(s: str) -> str:
52
+ return re.sub(r"[^\n]", GAP, s)
53
+
54
+
55
+ def _fence_ranges(lines: list[str]) -> list[tuple[int, int]]:
56
+ """Пары (первая, последняя) строк закрытых блоков кода, включая ограды."""
57
+ ranges: list[tuple[int, int]] = []
58
+ i = 0
59
+ while i < len(lines):
60
+ m = _FENCE_OPEN.match(lines[i])
61
+ # Инфо-строка ограды из кавычек не может содержать кавычки (CommonMark).
62
+ if not m or (m.group(1)[0] == "`" and "`" in m.group(2)):
63
+ i += 1
64
+ continue
65
+ ch, need = m.group(1)[0], len(m.group(1))
66
+ j = i + 1
67
+ while j < len(lines):
68
+ c = _FENCE_CLOSE.match(lines[j])
69
+ if c and c.group(1)[0] == ch and len(c.group(1)) >= need:
70
+ break
71
+ j += 1
72
+ if j >= len(lines):
73
+ i += 1 # незакрытая ограда: обычная строка
74
+ continue
75
+ ranges.append((i, j))
76
+ i = j + 1
77
+ return ranges
78
+
79
+
80
+ def _quote_lines(lines: list[str], in_code: set[int]) -> set[int]:
81
+ quoted: set[int] = set()
82
+ inside = False
83
+ for i, line in enumerate(lines):
84
+ if i in in_code:
85
+ inside = False
86
+ continue
87
+ if _QUOTE_LINE.match(line):
88
+ inside = True
89
+ quoted.add(i)
90
+ elif inside and line.strip() and not _BLOCK_START.match(line):
91
+ quoted.add(i) # ленивое продолжение абзаца цитаты
92
+ else:
93
+ inside = False
94
+ return quoted
95
+
96
+
97
+ def _classify(text: str) -> tuple[list[str], set[int], set[int]]:
98
+ lines = text.split("\n")
99
+ in_code: set[int] = set()
100
+ for a, b in _fence_ranges(lines):
101
+ in_code.update(range(a, b + 1))
102
+ return lines, in_code, _quote_lines(lines, in_code)
103
+
104
+
105
+ def _blank_short_quote(m: re.Match[str]) -> str:
106
+ inner = m.group(0)[1:-1]
107
+ return _blank(m.group(0)) if len(inner.split()) <= QUOTE_MAX_WORDS else m.group(0)
108
+
109
+
110
+ def mask_foreign(text: str) -> str:
111
+ """lexical: код, цитаты и короткие ёлочки заглушены, смещения сохранены."""
112
+ lines, in_code, quoted = _classify(text)
113
+ out: list[str] = []
114
+ for i, line in enumerate(lines):
115
+ if i in in_code or i in quoted:
116
+ out.append(_blank(line))
117
+ else:
118
+ out.append(_INLINE_CODE.sub(lambda m: _blank(m.group(0)), line))
119
+ return _QUOTE_MARKS.sub(_blank_short_quote, "\n".join(out))
120
+
121
+
122
+ def strip_foreign(text: str) -> str:
123
+ """prose: код и цитаты вырезаны, инлайн-код удалён, ёлочки на месте."""
124
+ lines, in_code, quoted = _classify(text)
125
+ kept = [_INLINE_CODE.sub("", line) for i, line in enumerate(lines)
126
+ if i not in in_code and i not in quoted]
127
+ return "\n".join(kept)