humanizer-ru 3.19.0

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,92 @@
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
+
34
+ def as_dict(self) -> dict:
35
+ return self.__dict__.copy()
36
+
37
+
38
+ _WORD_RE = re.compile(r"[А-Яа-яЁёA-Za-z0-9]")
39
+
40
+
41
+ def _word_count(sentence: str) -> int:
42
+ return sum(1 for t in tokenize(sentence) if _WORD_RE.search(t.text))
43
+
44
+
45
+ def rhythm(text: str) -> RhythmStats:
46
+ sents = [s.text for s in sentenize(text)]
47
+ lengths = [_word_count(s) for s in sents]
48
+ lengths = [n for n in lengths if n > 0]
49
+ n = len(lengths)
50
+ total_words = sum(lengths)
51
+
52
+ mean = statistics.mean(lengths) if lengths else 0.0
53
+ stdev = statistics.pstdev(lengths) if n > 1 else 0.0
54
+ cv = (stdev / mean) if mean else 0.0
55
+
56
+ return RhythmStats(
57
+ sentences=n,
58
+ words=total_words,
59
+ mean_len=round(mean, 1),
60
+ stdev_len=round(stdev, 1),
61
+ cv_len=round(cv, 3),
62
+ min_len=min(lengths) if lengths else 0,
63
+ max_len=max(lengths) if lengths else 0,
64
+ short_share=round(sum(1 for x in lengths if x < 5) / n, 3) if n else 0.0,
65
+ long_share=round(sum(1 for x in lengths if x > 25) / n, 3) if n else 0.0,
66
+ em_dash=text.count("—"),
67
+ ellipsis=text.count("…") + len(re.findall(r"\.\.\.", text)),
68
+ parentheses=text.count("("),
69
+ questions=sum(1 for s in sents if s.rstrip().endswith("?")),
70
+ )
71
+
72
+
73
+ # Эвристические пороги (откалиброваны под русский грубо, см. eval/RESULTS.md).
74
+ # cv_len < 0.35 — слишком ровный ритм, признак AI. Человеческий текст обычно > 0.45.
75
+ CV_AI_THRESHOLD = 0.35
76
+ CV_HUMAN_TARGET = 0.45
77
+
78
+
79
+ def rhythm_verdict(s: RhythmStats, dash_ok: bool = False) -> str:
80
+ """dash_ok=True для жанров, где длинное тире законно (научный,
81
+ юридический, художественный): тогда оно показывается фактом, не ⚠."""
82
+ if s.em_dash > 0 and dash_ok:
83
+ dash = f"тире: {s.em_dash} (законно для жанра)"
84
+ elif s.em_dash > 0:
85
+ dash = f"⚠ {s.em_dash} длинных тире (норма 0)"
86
+ else:
87
+ dash = "✓ тире чисто"
88
+ if s.cv_len < CV_AI_THRESHOLD:
89
+ rhythm_v = f"⚠ ровный ритм (CV={s.cv_len}, цель ≥{CV_HUMAN_TARGET})"
90
+ else:
91
+ rhythm_v = f"✓ ритм рваный (CV={s.cv_len})"
92
+ return f"{rhythm_v}; {dash}"
@@ -0,0 +1,439 @@
1
+ """Лексические маркеры AI-текста: HARD BANS и быстрый сканер.
2
+
3
+ Вордлисты вынесены один-в-один из прозы скилла: HARD BANS — из SKILL.md
4
+ (раздел «Жёсткие запреты»), категории сканера — из references/catalog.md
5
+ (раздел «Быстрый сканер: слова-маркеры AI», списки вынесены туда в v3.15.0).
6
+ Это детерминированная, грепабельная половина
7
+ режима «Аудит» — то, что не требует LLM. Семантика (кальки, ирония, translationese)
8
+ тут не ловится принципиально, для неё нужен сам скилл.
9
+
10
+ Источник правды — SKILL.md и references/catalog.md. При обновлении
11
+ банов/маркеров правь оба файла;
12
+ scripts/lint_skill.py проверяет, что списки не разошлись по числу пунктов.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+
20
+ # --- HARD BANS -----------------------------------------------------------
21
+ # 20 конструкций с абсолютным запретом. Каждая — (имя, regex). Regex
22
+ # регистронезависимый, по словоформам где это безопасно.
23
+ HARD_BANS: list[tuple[str, str]] = [
24
+ ("Не просто X, а Y", r"\bне\s+просто\b[^.!?]{1,40}?\bа\b"),
25
+ ("Не только X, но и Y", r"\bне\s+только\b[^.!?]{1,40}?\bно\s+и\b"),
26
+ ("В современном мире", r"\bв\s+современном\s+мире\b"),
27
+ ("Стоит отметить, что", r"\bстоит\s+отметить\b"),
28
+ ("Важно понимать, что", r"\bважно\s+понимать\b"),
29
+ # Только единственное число (как в SKILL.md HARD BAN). Множественные формы
30
+ # «данные/данных/данным/данными» совпадают с существительным «данные» (data)
31
+ # и давали ложные срабатывания на ML/статистических текстах.
32
+ ("Данный/Данная/Данное", r"\bданн(ый|ая|ое|ого|ой|ом|ую)\b"),
33
+ ("Является", r"\bявля(ется|ются)\b"),
34
+ ("Играет важную/ключевую роль", r"\bигра(ет|ют)\s+(важн\w+|ключев\w+)\s+роль\b"),
35
+ ("Можно с уверенностью сказать", r"\bможно\s+с\s+уверенностью\s+сказать\b"),
36
+ # «Таким образом» банится только как ВВОДНЫЙ коннектор-вывод (начало
37
+ # предложения либо обособлен запятыми с запятой следом): «...рынки. Таким
38
+ # образом, ...». Омонимичное обстоятельство образа действия «...таким
39
+ # образом, что...» (= «такой манерой») — живая речь, не AI-клише, под бан не
40
+ # идёт: перед ним стоит слово, а не граница предложения/запятая. На корпусе
41
+ # сужение не теряет ни одного AI-вхождения (все 7/7 raw — вводные «. Таким
42
+ # образом,»), но снимает ложные срабатывания на художественной прозе.
43
+ # «Подводя итог» однозначно итожащее — остаётся широким.
44
+ ("Подводя итог / Таким образом",
45
+ r"\bподводя\s+итог\b|(?:^|[.!?…:;]\s+|\n\s*(?:[-*•]\s*)?|,\s*)таким\s+образом\s*,"),
46
+ ("Длинное тире", r"—"),
47
+ # Короткое тире «–» (U+2013). Отдельным баном, потому что промахивается мимо
48
+ # регулярки на «—», а семейство gemma подменяет им длинное: парный замер на
49
+ # одних и тех же постах дал человека 6% против 80% у gemma3:4b и gemma3:27b,
50
+ # 64% у gemma3:1b. У остальных семейств сигнала нет (qwen 6-10%, Opus 0%),
51
+ # то есть это признак семейства, а не машинности вообще, но по силе лифта
52
+ # он обходит почти весь каталог. В русской типографике «–» законно только в
53
+ # числовых диапазонах, их и исключаем.
54
+ ("Короткое тире", r"(?<!\d)\s*–|–\s*(?!\d)"),
55
+ # Ложный диапазон — только несвязанные понятия. Семантику «несвязанности»
56
+ # регекс не проверит, поэтому исключаем заведомо легитимные классы:
57
+ # числа («от 10 до 15»), имена собственные («от Москвы до Твери» — операнды
58
+ # проверяются регистрозависимо через (?-i:...)), повтор слова («от зари до зари»).
59
+ ("от X до Y (ложный диапазон)",
60
+ r"\bот\s+(?-i:([а-яё]+))\s+до\s+(?-i:(?!\1\b)[а-яё]+)\b"),
61
+ ("В условиях [прил.] [сущ.]", r"\bв\s+условиях\s+\w+"),
62
+ ("Погрузимся / посмотрим поближе", r"\b(погрузимся|посмотрим\s+\w+\s+поближе|давайте\s+посмотрим)\b"),
63
+ ("И вот здесь начинается самое интересное", r"\bвот\s+(здесь|тут)\s+начинается\s+самое\s+интересное\b"),
64
+ ("Раскрыть потенциал", r"\bраскры(ть|ва\w+)\s+потенциал\b"),
65
+ ("Вывести на новый уровень", r"\bвыв(ести|одит)\s+на\s+новый\s+уровень\b"),
66
+ ("Комплексный подход/решение", r"\bкомплексн(ый|ое|ого|ым)\s+(подход\w*|решени\w+)\b"),
67
+ ("В связи с этим", r"\bв\s+связи\s+с\s+этим\b"),
68
+ # «Возможности» добавлены после прогона eval/mine_patterns.py: это самый
69
+ # частый вариант формулы, и он проходил мимо бана. На AINL-Eval 0.07% у
70
+ # людей против 2.43% у моделей (llama 5.3%), то есть разделение сильнее,
71
+ # чем у «горизонтов».
72
+ ("Открывает новые горизонты/перспективы/возможности",
73
+ r"\bоткрыва(ет|ют)\s+новые\s+(горизонт\w+|перспектив\w+|возможност\w+)\b"),
74
+ ]
75
+
76
+ # --- Быстрый сканер: категории слов-маркеров -----------------------------
77
+ # Лексический подсет категорий «Быстрого сканера» каталога (нелексические —
78
+ # ритм, структура — живут в других модулях: burstiness.py, structure.py).
79
+ # Семантические категории сканера («Фигуративный ноль», «Пустая образность»)
80
+ # кодом не покрываются нигде: это чисто смысловые проверки, их оценивает
81
+ # только LLM-слой аудита по каталогу. Элемент — либо
82
+ # фраза для подстрочного поиска (регистронезависимо; часть фраз — основы, чтобы
83
+ # ловить словоформы), либо строка с префиксом "re:" (регекс), либо пара
84
+ # (человекочитаемая метка, регекс) — метка идёт в отчёт вместо регекса.
85
+ SCANNER: dict[str, list[str | tuple[str, str]]] = {
86
+ "Канцелярит": [
87
+ "осуществлени", "реализаци", "внедрени", "оптимизаци", "функционировани",
88
+ "взаимодействи", "в рамках", "в целях", "в контексте", "на основе",
89
+ "посредством", "в соответствии с",
90
+ # Найдено шахтой (eval/mine_patterns.py), доли документов на AINL-Eval
91
+ # человек/AI: «внимание уделяется» 1.3/7-18%, «внимание уделено»
92
+ # 0.8/5-30%, «посвящена анализу» 2.7/10-29%. Все три держатся и на
93
+ # невиданной модели (unknown 8-16%). Категория глушится в академическом
94
+ # жанре, поэтому научной прозе они не мешают.
95
+ "внимание уделяется", "внимание уделено",
96
+ ("посвящена анализу/изучению/разработке",
97
+ r"посвящен[ао]\s+(анализ\w+|изучени\w+|разработк\w+|рассмотрени\w+)"),
98
+ ],
99
+ "Кальки": [
100
+ ("является", r"\bявля(ется|ются)\b"),
101
+ "стоит отметить", "важно понимать", "можно сказать",
102
+ "что касается", "в то время как", "с другой стороны", "тем не менее",
103
+ "несмотря на то что",
104
+ "стоит помнить", "следует учитывать", "необходимо отметить",
105
+ # Калька с «this study focuses on». Самые чистые ложные срабатывания из
106
+ # всей находки: 0.01% на людях против 2.2-2.9% у моделей. На невиданной
107
+ # модели не держится (0.18%), то есть это привычка семейств, а не след
108
+ # машины вообще, поэтому мягкий маркер, а не бан.
109
+ ("исследование фокусируется на",
110
+ r"(исследовани\w+|работ\w+|стать\w+)\s+фокусиру\w+\s+на"),
111
+ ],
112
+ "Раздувание": [
113
+ "ключев", "важнейш", "значительн", "огромн", "колоссальн", "переломн",
114
+ "фундаментальн", "невозможно переоценить", "играет роль",
115
+ ],
116
+ "Формула-выводы": [
117
+ # Вводный коннектор-вывод, суженный так же, как одноимённый хард-бан:
118
+ # обстоятельство «устроен таким образом, что...» — не маркер.
119
+ ("таким образом, (вводное)",
120
+ r"(?:^|[.!?…:;]\s+|\n\s*(?:[-*•]\s*)?|,\s*)таким\s+образом\s*,"),
121
+ "подводя итог", "в заключение", "можно сделать вывод",
122
+ "суммируя вышесказанное", "на основании вышеизложенного",
123
+ ],
124
+ "Чатбот": [
125
+ "конечно!", "отличный вопрос", "давайте разберёмся", "давайте разберемся",
126
+ "рад помочь", "надеюсь это было полезно", "с удовольствием",
127
+ ],
128
+ # «это не X, это Y» — конструкция, а не любое отрицание: «это не может не
129
+ # радовать» — живая речь, маркером не считается.
130
+ "Параллелизмы": ["не просто", "не только",
131
+ ("это не X, это Y", r"это\s+не\s+[^.!?\n]{1,40}[,—:-]\s*это\b")],
132
+ "Вводные-открытия": [
133
+ "в современном мире", "в эпоху", "не секрет что", "не секрет, что",
134
+ "всё чаще", "все чаще", "по мнению экспертов", "специалисты отмечают",
135
+ ],
136
+ "Модальные хеджи": [
137
+ "может стать", "может повлиять", "может быть полезн", "способен обеспечить",
138
+ "призван решить", "позволяет достичь",
139
+ ],
140
+ "Мотивационные клише": [
141
+ "раскрыть потенциал", "погрузимся", "вывести на новый уровень",
142
+ "открывает новые горизонты", "открывает новые возможности",
143
+ "расширить границы", "комплексный подход",
144
+ ],
145
+ "Контекстуализаторы": [
146
+ "в условиях", "в свете", "на фоне", "с учётом", "с учетом",
147
+ "принимая во внимание", "в связи с этим",
148
+ ],
149
+ "Маркетинговые штампы": [
150
+ "связь с аудиторией", "доверительные отношения", "ключевой момент",
151
+ "ключевая роль", "индивидуальный подход", "инновационное решение",
152
+ ],
153
+ "Псевдо-сократические": [
154
+ "зачем? потому что", "и что? а то", "почему? сейчас расскажу",
155
+ ],
156
+ "Авторитетные трюизмы": [
157
+ "вот ключевой вывод", "самое важное — это", "самое важное - это",
158
+ "первый шаг — это", "первый шаг - это", "по своей сути", "в конечном счёте",
159
+ "в конечном счете",
160
+ ],
161
+ "Псевдо-терапевтические": [
162
+ "ты не ошибаешься", "ты имеешь право", "тихое подтверждение",
163
+ "это не слабость", "ты настоящий", "сам факт этого",
164
+ ],
165
+ # Паттерн #54 (приоритет A): текст присваивает себе добродетель честности
166
+ # вместо предъявления фактов. Гасим класс ложных срабатываний из каталога:
167
+ # одиночное разговорное «честно» / «честно говоря» (частица живой речи,
168
+ # паттерн #32) и жанровый заголовок «честный обзор» (человеческая конвенция
169
+ # кликбейта) НЕ ловятся — голого «честно» и «честного обзора» в списке нет,
170
+ # только фразы самомаркировки. Остаточный риск: буквальное «без воды»
171
+ # (кулинария, химия) и «объективный разбор» о чужом тексте — редки, гасятся
172
+ # шкалой вердикта (одиночный маркер не валит текст). Каталожный порог
173
+ # «2+ на текст или маркировка в заголовке секции» зафиксирован здесь
174
+ # комментарием: архитектура SCANNER считает поштучно, плотностных порогов
175
+ # на категорию у неё нет.
176
+ "Самомаркировка честности": [
177
+ ("честный список", r"\bчестн(?:ый|ого|ому|ым|ом)\s+спис\w*\b"),
178
+ ("буду (с вами) откровенен", r"\bбуду\s+(?:с\s+(?:вами|тобой)\s+)?откровен\w*\b"),
179
+ "без прикрас",
180
+ ("без воды", r"\bбез\s+воды\b"),
181
+ ("объективный разбор", r"\bобъективн\w+\s+разбор\w*\b"),
182
+ ("это не очередной", r"\bэто\s+не\s+очередн\w*\b"),
183
+ ],
184
+ "Эмодзи-декор": ["⚡", "✨", "🎯", "🔥", "💡"],
185
+ # Однозначные улики подмены символов (SKILL.md, «Технические маркеры»):
186
+ # латинские гомоглифы внутри кириллического слова и невидимые символы.
187
+ # Легитимная латиница (IT, web, URL) кириллицей с двух сторон не окружена.
188
+ "Технические маркеры": [
189
+ ("латиница внутри кириллического слова", r"(?<=[а-яё])[a-z](?=[а-яё])"),
190
+ # zero-width (ZWSP/ZWNJ/ZWJ/BOM) + word joiner, Mongolian vowel
191
+ # separator, bidi-метки и изоляторы: клавиатурой не набираются,
192
+ # в живом русском тексте не встречаются.
193
+ ("невидимые символы (zero-width, bidi)",
194
+ "[​‌‍⁠᠎‎‏"
195
+ "‪-‮⁦-⁩؜]"),
196
+ ],
197
+ # Паттерн #55: подлежащее-предмет при глаголе намерения. Английские
198
+ # источники описывают то же самое как смену AI-словаря на emphasizing /
199
+ # highlighting / showcasing (Wikipedia AI Cleanup, обновление 2026).
200
+ "Неодушевлённый субъект": [
201
+ ("предмет или абстракция подчёркивает/демонстрирует",
202
+ r"\b(исследовани\w+|работ[аы]|стать\w+|проект\w*|платформ\w+|систем\w+|"
203
+ r"решени\w+|компани\w+|здани\w+|документ\w*|отчёт\w*|инициатив\w+|"
204
+ r"технологи\w+|подход\w*)\s+"
205
+ r"(подчёркива\w+|демонстрир\w+|отража\w+|обеспечива\w+|иллюстрир\w+|"
206
+ r"символизир\w+|подтвержда\w+)\b"),
207
+ ],
208
+ # Паттерн #58: формула 2026 года «No X. No Y. Just Z.» в русском изводе.
209
+ "Триада-отрицание": [
210
+ ("Без X. Без Y. Только Z.",
211
+ r"(?:^|[.!?…\n]\s*)(?:без|ни)\s+[^.!?\n]{1,40}[.!?]\s+(?:без|ни)\s+"
212
+ r"[^.!?\n]{1,40}[.!?]\s+(?:только|просто|лишь)\b"),
213
+ ],
214
+ # Паттерн #59 (категория N, дискурсный слой): нарратор проговаривает мораль
215
+ # прямым текстом. StoryScope (arXiv:2604.03136v6, COLM 2026), параллельный
216
+ # корпус 61 608 историй: явная мораль у ИИ в 77% текстов против 52% у
217
+ # людей. Разрыв в полтора раза, то есть половина людей делает то же самое:
218
+ # маркер мягкий, не бан, и один его хит текст не валит.
219
+ # Кодом ловится только узкий лексический подкласс - шаблонные формулы
220
+ # морали; остальная категория N нелексическая и оценивается LLM-слоем
221
+ # аудита по каталогу, как «Фигуративный ноль».
222
+ # Три сужения против ложных срабатываний на живой прозе:
223
+ # 1) указательное «этой/сей/эта» обязательно - иначе под маркер уходят
224
+ # разбор басни («мораль истории про лису») и публицистический оборот
225
+ # «история учит нас осторожности», который человек пишет свободно;
226
+ # 2) окончания после «истори» перечислены явно - \w цеплял «историк»
227
+ # («наш историк учит нас работать с источниками»);
228
+ # 3) голые «мораль проста/такова» считаются только последней фразой
229
+ # текста: в середине это разговорная связка («в басне мораль проста,
230
+ # а исполнение так себе»), в финале - тот самый дискурсный признак.
231
+ "Финальная мораль": [
232
+ ("мораль этой истории",
233
+ r"\bмораль\s+(?:этой|всей\s+этой|сей)\s+истори(?:и|ю|ей)\b"),
234
+ ("эта история учит нас",
235
+ r"\b(?:эта|вся\s+эта)\s+истори(?:я|и)\s+(?:учит|научила|учила)\s+(?:нас|нам)\b"),
236
+ ("эта история о том, что",
237
+ r"\bэта\s+истори(?:я|и)\s+о\s+том,\s+что\b"),
238
+ ("мораль проста (последней фразой)",
239
+ r"[.!?…]\s+мораль\s+(?:проста|такова)\b[^.!?]{0,60}[.!?]\s*$"),
240
+ ],
241
+ # Технические следы копирования из интерфейса чат-бота. Однозначные улики:
242
+ # в человеческом тексте не встречаются.
243
+ # Префикс "re:" = регулярное выражение, остальное — подстрока.
244
+ "Артефакты копипасты": [
245
+ ":contentReference", "oai_citation", "utm_source=chatgpt.com",
246
+ "utm_source=openai", "grok_card://", "attached_file://",
247
+ "vertexaisearch.cloud.google.com/grounding-api-redirect",
248
+ "](sandbox:/mnt/data/", "attributableIndex",
249
+ r"re:oaicite:\d+",
250
+ r"re:turn\d+(?:search|fetch|file)\d+", # turnNsearchN и родня
251
+ r"re:citeturn\d+[a-z]+\d+", # слитная метка цитаты
252
+ r"re:【\d+(?::\d+)?†source】", # OpenAI Assistants
253
+ r"re:\[citation:\d+\]", # Perplexity-стиль
254
+ "re:[\ue200-\ue204]", # невидимые разделители цитат ChatGPT
255
+ r"re:</?think>", # остатки рассуждения DeepSeek и др.
256
+ # Замечены в 2026 (Wikipedia AI Cleanup, разделы про артефакты разметки):
257
+ r"re::::writing\{variant=", # обёртка документа, с июня 2026
258
+ r"re:\[cite:\s*\d+\]", # метка цитаты Gemini
259
+ r"re:\[span_\d+\]\(start_span\)", # обёртка фрагмента Gemini
260
+ "grok_render_citation_card_json", # карточка цитаты Grok
261
+ ],
262
+ }
263
+
264
+ @dataclass
265
+ class MarkerHit:
266
+ category: str
267
+ marker: str
268
+ count: int
269
+ positions: list[int] = field(default_factory=list)
270
+
271
+
272
+ def _find_all(text: str, needle_regex: str) -> list[int]:
273
+ return [m.start() for m in re.finditer(needle_regex, text, re.IGNORECASE)]
274
+
275
+
276
+ def scan_hard_bans(text: str) -> list[MarkerHit]:
277
+ """Находит конструкции из HARD BANS. Любое попадание = провал."""
278
+ hits: list[MarkerHit] = []
279
+ for name, pat in HARD_BANS:
280
+ pos = _find_all(text, pat)
281
+ if pos:
282
+ hits.append(MarkerHit("HARD BAN", name, len(pos), pos))
283
+ return hits
284
+
285
+
286
+ # Частотные пороги из SKILL.md: маркер банится не с первого вхождения, а по
287
+ # плотности. «Является» — легитимная связка в меру (термины, определения):
288
+ # таблица HARD BANS задаёт «>1 раз на 500 слов». Одиночное вхождение остаётся
289
+ # мягким маркером («Кальки»), scan_hard_bans его по-прежнему находит.
290
+ FREQ_BANS: dict[str, int] = {"Является": 500}
291
+
292
+
293
+ # --- Почему тире остаётся баном, хотя частоты говорят обратное -------------
294
+ # Класс ошибки, который тут однажды был допущен и откачен в тот же день.
295
+ #
296
+ # Если считать ДОЛЮ ДОКУМЕНТОВ в несопряжённых корпусах, тире выглядит признаком
297
+ # человека: AINL человек 4.8% против 0.0-0.9% у моделей, M4 человек 42.3% против
298
+ # 3.3%, Пикабу человек 14.0%. Вывод напрашивается сам, и он неверный по двум
299
+ # причинам сразу: человеческие половины там это изданная вычитанная проза, где
300
+ # тире ставит редактор, а машинные половины сделаны генераторами 2023-2024 годов.
301
+ #
302
+ # Продуктовый вопрос звучит не так. Пользователь отдаёт модели СВОЙ текст, и
303
+ # важно, что она с ним делает. Это парный замер на одних и тех же постах, и он
304
+ # даёт противоположный ответ (доля пар, где тире не было у человека, но
305
+ # появилось у машины):
306
+ #
307
+ # Qwen3-4B-heretic 45 из 45 человек 10% -> машина 100%
308
+ # qwen2.5:7b 19 из 31 человек 11% -> машина 66%
309
+ # qwen3-vl:4b 5 из 9 человек 10% -> машина 60%
310
+ # Opus 5 4 из 15 человек 0% -> машина 27%
311
+ # gemma3:27b 4 из 45 человек 10% -> машина 14%
312
+ #
313
+ # Семейство gemma тире почти не добавляет, но подменяет его коротким (см. ниже).
314
+ #
315
+ # Ложные срабатывания на изданной прозе при этом реальны, и лечатся они не
316
+ # отменой бана, а жанровым фильтром: в academic и legal тире уже снимается.
317
+ FREQ_BANS_NOTE = "см. комментарий выше про парный замер"
318
+
319
+
320
+ # --- Жанровый фильтр ------------------------------------------------------
321
+ # Часть банов в отдельных жанрах не признак машины, а норма регистра. Для
322
+ # академического текста это измерено: прогон по AINL-Eval 2025 (35 158 русских
323
+ # научных аннотаций с разметкой human / gpt-4-turbo / gemma-2-27b /
324
+ # llama-3.3-70b) дал 35.9% документов с баном у ЛЮДЕЙ против 26.1-35.8% у
325
+ # моделей: маркеры не разделяли классы вовсе. «Является» сработало на 8.0%
326
+ # человеческих текстов и 0.1-3.0% машинных, длинное тире на 4.8% у людей и
327
+ # 0.0-0.9% у моделей. С фильтром ниже человеческие срабатывания падают до 4.2%.
328
+ # Цифры и метод: eval/ainl_calibration.py, SOURCES.md.
329
+ #
330
+ # Жанры legal и fiction фильтруются по тем же правилам, что записаны в разделе
331
+ # «Ложные срабатывания» SKILL.md; корпусной проверки под них у нас пока нет.
332
+ GENRE_MUTED_BANS: dict[str, set[str]] = {
333
+ "academic": {
334
+ "Является", "Длинное тире", "Короткое тире", "Данный/Данная/Данное",
335
+ "В условиях [прил.] [сущ.]", "Подводя итог / Таким образом",
336
+ "В связи с этим",
337
+ },
338
+ "legal": {
339
+ "Является", "Данный/Данная/Данное", "В условиях [прил.] [сущ.]",
340
+ "В связи с этим", "Длинное тире", "Короткое тире",
341
+ },
342
+ "fiction": {
343
+ "Длинное тире", "Короткое тире", "от X до Y (ложный диапазон)",
344
+ "Не только X, но и Y",
345
+ "Не просто X, а Y",
346
+ },
347
+ }
348
+
349
+ GENRE_MUTED_CATEGORIES: dict[str, set[str]] = {
350
+ "academic": {"Канцелярит", "Кальки", "Контекстуализаторы", "Формула-выводы"},
351
+ "legal": {"Канцелярит", "Кальки", "Контекстуализаторы"},
352
+ "fiction": {"Параллелизмы"},
353
+ }
354
+
355
+ GENRES = ("marketing", "academic", "legal", "fiction")
356
+
357
+
358
+ # --- Что проверено корпусом, а что нет ------------------------------------
359
+ # Каталог собирался вручную, и до 2026 года ни один его пункт не был проверен
360
+ # на том, отличает ли он вообще человека от машины. Теперь часть проверена, и
361
+ # честнее показывать разницу, чем выдавать всё за одинаково обоснованное.
362
+ #
363
+ # Ниже категории, которые разделяют классы В ДВУХ РАЗНЫХ ДОМЕНАХ сразу: на
364
+ # научных аннотациях (AINL-Eval 2025, 35 158 текстов, генераторы gpt-4-turbo,
365
+ # gemma-2-27b, llama-3.3-70b) и на смешанном регистре (M4-ru, 11 518 текстов,
366
+ # генераторы gpt-3.5-turbo и davinci-003). Число это лифт, отношение частоты у
367
+ # машины к частоте у человека; всё, что ниже 1, работает против нас.
368
+ #
369
+ # Остальные пункты каталога либо не проверялись, либо переворачиваются при
370
+ # смене домена: «Является» даёт 0.1 на научном тексте и 4.5 на смешанном,
371
+ # «Данный» 0.9 и 3.1, кальки 0.3 и 2.0. Канцелярит не разделяет ни там, ни там,
372
+ # хотя в каталоге назван главным маркером. Это не повод их выбрасывать: маркер
373
+ # без указания домена просто бессмыслен, о чём написано в SOURCES.md.
374
+ CROSS_DOMAIN_VERIFIED: dict[str, tuple[float, float]] = {
375
+ # категория или бан: (лифт на AINL, лифт на M4)
376
+ "Играет важную/ключевую роль": (5.7, 9.4),
377
+ "Мотивационные клише": (2.8, 4.0),
378
+ "Неодушевлённый субъект": (4.4, 2.7),
379
+ "Комплексный подход/решение": (3.0, 2.8),
380
+ "Модальные хеджи": (2.6, 2.9),
381
+ "Раздувание значимости": (1.8, 1.4),
382
+ }
383
+
384
+
385
+ def mute_by_genre(hits: list[MarkerHit], genre: str | None,
386
+ muted: dict[str, set[str]]) -> list[MarkerHit]:
387
+ """Снимает маркеры, законные для жанра. Без жанра ничего не снимает:
388
+ умолчание остаётся строгим (маркетинг и блоги, под них калибровка)."""
389
+ names = muted.get(genre or "", set())
390
+ return [h for h in hits if h.marker not in names and h.category not in names]
391
+
392
+
393
+ def effective_hard_bans(hits: list[MarkerHit], words: int) -> list[MarkerHit]:
394
+ """Хард-баны после частотных порогов: то, что реально штрафуется/валит exit.
395
+
396
+ Для маркеров из FREQ_BANS попадание остаётся баном только при двух и более
397
+ вхождениях с плотностью выше 1 на N слов. Остальные баны проходят как есть.
398
+ """
399
+ out: list[MarkerHit] = []
400
+ for h in hits:
401
+ per = FREQ_BANS.get(h.marker)
402
+ if per and (h.count < 2 or h.count <= words / per):
403
+ continue
404
+ out.append(h)
405
+ return out
406
+
407
+
408
+ def scan_markers(text: str) -> list[MarkerHit]:
409
+ """Прогоняет лексические категории быстрого сканера (SCANNER). Возвращает только попадания.
410
+
411
+ Элемент с префиксом "re:" трактуется как регулярное выражение,
412
+ остальные — как литеральная подстрока (регистронезависимо).
413
+ """
414
+ hits: list[MarkerHit] = []
415
+ for cat, phrases in SCANNER.items():
416
+ for phrase in phrases:
417
+ if isinstance(phrase, tuple):
418
+ label, pat = phrase
419
+ elif phrase.startswith("re:"):
420
+ label, pat = phrase[3:], phrase[3:]
421
+ else:
422
+ label, pat = phrase, re.escape(phrase)
423
+ pos = _find_all(text, pat)
424
+ if pos:
425
+ hits.append(MarkerHit(cat, label, len(pos), pos))
426
+ return hits
427
+
428
+
429
+ def marker_verdict(marker_hits: list[MarkerHit]) -> str:
430
+ """Шкала из SKILL.md: 0-2 чисто, 3-5 подозрительно, 6+ AI.
431
+ Артефакт копипасты — однозначная улика: вердикт сразу, без шкалы."""
432
+ if any(h.category == "Артефакты копипасты" for h in marker_hits):
433
+ return "артефакты копипасты из чат-бота — текст вставлен из ответа ИИ"
434
+ total = sum(h.count for h in marker_hits)
435
+ if total <= 2:
436
+ return f"{total} — скорее всего чистый текст"
437
+ if total <= 5:
438
+ return f"{total} — подозрительно"
439
+ return f"{total} — AI-генерация с высокой вероятностью"
@@ -0,0 +1,78 @@
1
+ """Морфология: соотношение существительных к глаголам через pymorphy3.
2
+
3
+ Закрывает пункт чеклиста «соотношение существительных к глаголам ≤ 2.5:1» —
4
+ тот, который LLM не умеет считать на глаз. У AI-русского ~3:1, у людей ~2:1
5
+ (SKILL.md, паттерн #14, Biber framework). Чем выше отношение, тем «номинальнее»
6
+ текст, тем сильнее канцелярит.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ from dataclasses import dataclass
13
+
14
+ import pymorphy3
15
+ from razdel import tokenize
16
+
17
+
18
+ @functools.lru_cache(maxsize=1)
19
+ def _morph() -> "pymorphy3.MorphAnalyzer":
20
+ return pymorphy3.MorphAnalyzer()
21
+
22
+
23
+ @dataclass
24
+ class MorphStats:
25
+ nouns: int
26
+ verbs: int # глаголы + инфинитивы + деепричастия + причастия как глагольные формы
27
+ noun_verb_ratio: float
28
+ nominalizations: int # отглагольные существительные (потенциальный канцелярит)
29
+ tokens: int
30
+
31
+ def as_dict(self) -> dict:
32
+ return self.__dict__.copy()
33
+
34
+
35
+ # Что считаем «глагольным» — pymorphy3 теги (OpenCorpora):
36
+ _VERBAL = {"VERB", "INFN", "GRND", "PRTF", "PRTS"}
37
+ _NOUN = {"NOUN"}
38
+ _NOMINAL_SUFFIXES = ("ение", "ание", "ания", "ения", "ация", "изация", "ировка", "ость", "остей")
39
+
40
+
41
+ def _is_cyrillic_word(tok: str) -> bool:
42
+ return any("а" <= c.lower() <= "я" or c.lower() == "ё" for c in tok)
43
+
44
+
45
+ def morph_stats(text: str) -> MorphStats:
46
+ m = _morph()
47
+ nouns = verbs = nominal = total = 0
48
+ for t in tokenize(text):
49
+ w = t.text
50
+ if not _is_cyrillic_word(w):
51
+ continue
52
+ total += 1
53
+ p = m.parse(w)[0]
54
+ pos = p.tag.POS
55
+ if pos in _NOUN:
56
+ nouns += 1
57
+ lemma = p.normal_form
58
+ if lemma.endswith(_NOMINAL_SUFFIXES):
59
+ nominal += 1
60
+ elif pos in _VERBAL:
61
+ verbs += 1
62
+ ratio = (nouns / verbs) if verbs else float(nouns)
63
+ return MorphStats(
64
+ nouns=nouns,
65
+ verbs=verbs,
66
+ noun_verb_ratio=round(ratio, 2),
67
+ nominalizations=nominal,
68
+ tokens=total,
69
+ )
70
+
71
+
72
+ NV_TARGET = 2.5 # из чеклиста SKILL.md
73
+
74
+
75
+ def morph_verdict(s: MorphStats) -> str:
76
+ if s.noun_verb_ratio <= NV_TARGET:
77
+ return f"✓ сущ./глаг. = {s.noun_verb_ratio} (цель ≤{NV_TARGET})"
78
+ return f"⚠ сущ./глаг. = {s.noun_verb_ratio} (цель ≤{NV_TARGET}, канцелярит); номинализаций: {s.nominalizations}"