humanizer-ru 3.28.0 → 3.29.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.
package/README.en.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Claude Code / Cowork plugin. Kills AI smell in Russian text. The English [humanizer](https://github.com/blader/humanizer) won't help here. Russian AI markers are their own beast: bureaucratic noun-chains (канцелярит), English-syntax calques, missing particles like "же" and "ведь" that make Russian sound alive.
6
6
 
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![Version](https://img.shields.io/badge/version-3.28.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Version](https://img.shields.io/badge/version-3.29.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
9
9
  [![Stars](https://img.shields.io/github/stars/ilyautov/humanizer-ru?style=social)](https://github.com/ilyautov/humanizer-ru/stargazers)
10
10
  [![skills.sh](https://skills.sh/b/ilyautov/humanizer-ru)](https://skills.sh/ilyautov/humanizer-ru/humanizer-ru)
11
11
  [![npm](https://img.shields.io/npm/v/humanizer-ru?label=npm%20%C2%B7%20dsh)](https://www.npmjs.com/package/humanizer-ru)
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > [English version](README.en.md) · [中文](README.zh.md)
6
6
 
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![Версия](https://img.shields.io/badge/версия-3.28.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Версия](https://img.shields.io/badge/версия-3.29.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
9
9
  [![Звёзды](https://img.shields.io/github/stars/ilyautov/humanizer-ru?style=social)](https://github.com/ilyautov/humanizer-ru/stargazers)
10
10
  [![skills.sh](https://skills.sh/b/ilyautov/humanizer-ru)](https://skills.sh/ilyautov/humanizer-ru/humanizer-ru)
11
11
  [![npm](https://img.shields.io/npm/v/humanizer-ru?label=npm%20%C2%B7%20dsh)](https://www.npmjs.com/package/humanizer-ru)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanizer-ru",
3
- "version": "3.28.0",
3
+ "version": "3.29.0",
4
4
  "description": "Agent skill for DeepSeek Harness: rewrites Russian text to remove 64 markers of AI generation (bureaucratese, calques, ChatGPT fingerprints), with a corpus-calibrated scanner, audit mode and author-voice calibration.",
5
5
  "license": "MIT",
6
6
  "author": "Ilya Utov",
@@ -4,7 +4,7 @@ description: "Качество русского текста для агента
4
4
  license: MIT
5
5
  ---
6
6
 
7
- # Humanizer-RU v3.28.0
7
+ # Humanizer-RU v3.29.0
8
8
 
9
9
  Ты редактор. Убираешь из русского текста следы нейросети и не ломаешь при этом
10
10
  смысл, факты и голос автора. Оба обещания равноправны: текст, который стал «чище»
@@ -46,13 +46,16 @@ license: MIT
46
46
 
47
47
  ## Шаг 1. Диагностика
48
48
 
49
- **Сканер.** Если доступны команды и python3 с `razdel` и `pymorphy3`, сначала
50
- прогони текст: `python3 <папка скилла>/scripts/scan.py файл.txt` или
49
+ **Сканер.** Если доступны команды, первый вызов инструмента в редактуре это
50
+ сканер, до каталога и до правки: `python3 <папка скилла>/scripts/scan.py файл.txt` или
51
51
  `echo "текст" | python3 <папка скилла>/scripts/scan.py -`. Для научного,
52
52
  юридического, художественного текста и новостей добавь `--genre academic|legal|fiction|news`:
53
- без жанра сканер ловит учёных чаще, чем нейросети. Запомни балл «было». Пакетов нет или сканер
54
- упал: скажи пользователю одной строкой, что сканер не запустился и балла не
55
- будет (как поставить, написано в его ошибке и в README), и работай вручную.
53
+ без жанра сканер ловит учёных чаще, чем нейросети. Запомни балл «было». Если
54
+ `scan.py` упал с «не хватает зависимостей», а `uvx` есть в PATH, запусти то же
55
+ через `uvx ru-humanizer` с теми же аргументами (`-` для stdin, `--genre`, `--before`):
56
+ зависимости он поставит сам. Упал и он или команд нет вовсе: скажи пользователю
57
+ одной строкой, что сканер не запустился и балла не будет (как поставить, написано
58
+ в его ошибке и в README), и работай вручную.
56
59
  Молча пропускать сканер нельзя: без балла правка не измерима, а пользователь
57
60
  месяцами не узнает, что половина скилла не работает. Балл на глаз не придумывай.
58
61
 
@@ -76,9 +79,9 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
76
79
 
77
80
  | Группа | Что это | Когда править |
78
81
  |---|---|---|
79
- | A | Жёсткие запреты (таблица ниже), пустые открытия, канцелярит (отглагольные существительные «осуществление», «внедрение», «реализация», «обеспечение», «взаимодействие», «оптимизация», «функционирование» и «в рамках», «в целях» → глагол или предлог), артефакты чатбота («Давайте разберёмся», «Надеюсь, помог»), негативные параллелизмы, модальная неопределённость («может показаться», «в некотором смысле»), псевдо-терапия («ты имеешь право так чувствовать») | Всегда, в любом регистре кроме цитат |
80
- | B | Размытые авторитеты («эксперты считают»), кальки и пунктуационные кальки, «является» через предложение, водянистость, «определённый»/«соответствующий», ровные абзацы одной длины и гладкие переходы, мораль в финале, портретный ввод героя, эмодзи-декор, translationese | Везде, кроме юридических текстов |
81
- | C | Раздувание значимости, формульные выводы, правило трёх (искусственные триады синонимов), карусель синонимов, частые тире, оговорки, отсутствие идиом, контр-вопросы («Зачем? Потому что.»), рваная медитативность («Точно. Отдельно.») | Маркетинг и экспертный текст |
82
+ | A | Жёсткие запреты (таблица ниже), пустые открытия, канцелярит (отглагольные существительные «осуществление», «внедрение», «реализация», «обеспечение», «взаимодействие», «оптимизация», «функционирование» и «в рамках», «в целях» → глагол или предлог), артефакты чатбота («Давайте разберёмся», «Надеюсь, помог»), обвязка чата (вступление «Вот вариант:», предложение доработки, советы после самого текста снимаются; у «Вариант 1/2» снимается метка, а все варианты остаются, если пользователь не просил выбрать; плейсхолдер «[Имя]» не заполняй, перечисли пользователю), негативные параллелизмы, оговорки мнения («может показаться», «в некотором смысле»), псевдо-терапия («ты имеешь право так чувствовать») | Всегда, в любом регистре кроме цитат |
83
+ | B | Размытые авторитеты («эксперты считают»), кальки и пунктуационные кальки, «является» через предложение, водянистость, «определённый»/«соответствующий», ровные абзацы одной длины и гладкие переходы, мораль в финале, портретный ввод героя, эмодзи-декор, translationese; в постах и письмах подзаголовки «##» и строки целиком жирным становятся обычными строками | Везде, кроме юридических текстов |
84
+ | C | Раздувание значимости, формульные выводы, правило трёх (искусственные триады синонимов), карусель синонимов, частые тире, отсутствие идиом, контр-вопросы («Зачем? Потому что.»), рваная медитативность («Точно. Отдельно.»), почерк модели (определение через тире, «стал настоящим открытием», «вдохновляет», «гармония», «напоминает о», «подчёркивает») | Маркетинг и экспертный текст |
82
85
  | D | Болд, кавычки, списки, типографика | По контексту |
83
86
 
84
87
  Полные описания и примеры «было и стало» по номерам паттернов в каталоге.
@@ -112,7 +115,7 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
112
115
  | «Играет важную/ключевую роль» | Оставь только то, ПОЧЕМУ важно, если это есть в тексте |
113
116
  | «Можно с уверенностью сказать» | Скажи без преамбулы |
114
117
  | «Подводя итог», «Таким образом,» в начале вывода | Убери или начни вывод с действия. «Устроен таким образом, что» не маркер |
115
- | Длинное тире «—» и короткое «–» вне числовых диапазонов | Запятая, двоеточие, точка, дефис или перестройка. Убирается всегда; оставить тире можно только по явной просьбе пользователя |
118
+ | Длинное тире «—», короткое «–» вне числовых диапазонов и дефис с пробелами в роли тире | Запятая, двоеточие, точка или перестройка. Тире между подлежащим и сказуемым («Антитела [тире] это белки») перестраивай глаголом: «Антитела относятся к белкам», «называются», «служат». Голое «Антитела это белки» читается как пунктуационная ошибка, запятая делает сказуемое приложением. Убирается всегда; оставить тире можно только по явной просьбе пользователя |
116
119
  | «От X до Y» для несвязанных понятий | Перечисли конкретно или убери |
117
120
  | «Погрузимся в...», «Давайте посмотрим поближе», «И вот здесь начинается самое интересное» | Удали анонс, сразу пиши суть |
118
121
  | «Раскрыть потенциал», «Вывести на новый уровень», «Открывает новые горизонты/перспективы/возможности» | Конкретный результат, если он назван в тексте; иначе удали |
@@ -143,6 +146,10 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
143
146
 
144
147
  > **Факт-замок (приоритет над любым паттерном).** Числа, даты, имена, названия,
145
148
  > проценты, единицы, условия и оговорки исходника переносятся без искажений.
149
+ > Оговорка при факте («может быть», «часто», «обычно», «примерно», «от 5000»,
150
+ > «не обязательно», «ожидаем») задаёт его точность и не снимается; снимаются
151
+ > только оговорки мнения из группы A. Сканер с `--before исходник.txt` печатает
152
+ > снятую оговорку строкой «потеряно: оговорка:…»; стояла она при факте, верни её.
146
153
  > Добавлять факты, которых в исходнике нет (цифры, исследования, кейсы, «у себя
147
154
  > в команде вижу»), запрещено. В исходнике нет конкретики, а оборот пустой?
148
155
  > Удали оборот или спроси пользователя, чем наполнить. Выдуманная цифра хуже
@@ -182,17 +189,22 @@ SKILL.md, без твоих рассуждений и без списка изм
182
189
  случайный читатель в ленте, и по тому же брифу: не осталось ли конструкций из
183
190
  таблицы запретов, не повторяются ли в твоих вставках одинаковые концовки и
184
191
  подводки, не выровнялись ли абзацы под одну длину. Отдельно найди в чистовике
185
- символы «—» и «–»: их не должно остаться нигде, кроме числовых диапазонов, и это
186
- самая частая недоделка. Если сканер был доступен, прогони результат ещё раз.
192
+ «—», «–» и « - »: их не должно остаться нигде, кроме числовых диапазонов, и это
193
+ самая частая недоделка. Без сканера ищи там же «не просто», «не только», «это не»
194
+ и английские слова внутри русских фраз. Если сканер был доступен, прогони
195
+ результат ещё раз.
187
196
  Правка закончена, когда отмеченные недостатки сняты, а новые изменения не дают
188
197
  читателю понятной пользы. Замечания остались после второго круга? Верни текст с
189
198
  их списком, третий круг сглаживает содержание ради балла.
190
199
 
191
200
  ## Отчёт
192
201
 
193
- По умолчанию верни итоговый текст. Если сканер запускался, добавь строку
194
- «Чистота: было N, стало M» и одну-две фразы, что именно снято. Объяснение всех
195
- изменений давай по запросу. Балл это правила сканера, а не вероятность ИИ и не
202
+ По умолчанию верни итоговый текст. Если сканер запускался, добавь после текста
203
+ строку «Чистота: было N, стало M» и одну-две фразы, что именно снято; просили
204
+ только текст, строки нет. Финальное сообщение начинается с первой строки текста:
205
+ перед ним нет балла, вердикта сканера, «вот итоговый текст» и заметок о файлах
206
+ и правах, а отчёт после текста пишется на языке пользователя. Объяснение всех изменений
207
+ давай по запросу. Балл это правила сканера, а не вероятность ИИ и не
196
208
  вердикт об авторстве; скилл не обходит детекторы и антиплагиат, не вставляет
197
209
  опечатки и не подменяет буквы. Если текст уже хорош, скажи об этом и не правь
198
210
  ради правки.
@@ -91,7 +91,7 @@ davinci-003). Метод и полные таблицы: `eval/AINL-CALIBRATION.
91
91
 
92
92
  **12. Правило трёх.** AI обожает перечисления из трёх: «важный, значительный и ключевой». Если три синонима, оставь один. Если перечисление искусственное, перестрой фразу. Иногда хватит двух элементов. Иногда нужно четыре.
93
93
 
94
- **13. Синонимическая карусель.** AI чередует «компания», «организация», «предприятие», «фирма» для одной сущности. Выбери одно слово. Повтор в русском нормален. Неестественное чередование хуже повтора.
94
+ **13. Синонимическая карусель.** AI чередует «компания», «организация», «предприятие», «фирма» для одной сущности. Выбери одно слово. Повтор в русском нормален. Неестественное чередование хуже повтора. Сканер видит обратную сторону статистически: если на тексте от 100 слов слова почти не повторяются (MATTR выше 0.955 при норме людей 0.90), он даёт штраф «лексическое разнообразие». Лечится тем же: верни повтор или местоимение вместо подобранного синонима, не выискивай новых слов.
95
95
 
96
96
  **14. Перекос в существительные.** В AI-тексте соотношение существительных к глаголам примерно 3:1, у людей ближе к 2:1. LLM предпочитает noun phrases, люди заякоривают язык в глаголах с временем и наклонением. Instruction tuning усиливает перекос: ChatGPT известен «номинальным» стилем, в AI-тексте больше номинализаций. Если в абзаце мало глаголов, ищи номинализации и разворачивай. Детекторы ловят это даже на чистом синтаксисе без лексики: паттерны синтаксических связей различают human/AI.
97
97
 
@@ -343,6 +343,10 @@ davinci-003). Метод и полные таблицы: `eval/AINL-CALIBRATION.
343
343
 
344
344
  **Финальная мораль:** «мораль этой истории», «эта история учит нас», «эта история о том, что», а также «мораль проста»/«мораль такова» последней фразой текста: нарратор проговаривает вывод за читателя (паттерн #59). Указательное слово обязательно, иначе под маркер уходит живая проза: разбор басни («мораль истории про лису»), публицистическое «история учит нас осторожности», «наш историк учит нас». Сканер ловит только шаблонные формулы; мораль, изложенную без них, оценивай по прочтении
345
345
 
346
+ **Почерк модели:** «подчёркивает», «свидетельствует о», «демонстрирует», «это не просто», «что делает его», «важность», «заставляет задуматься», «стал символом», «стал настоящим открытием», «важный шаг», «представьте», «заслуживает», «для тех, кто», «Это позволяет», «вдохновляет», «незабываемый», «гармония», «X — это Y» (только длинное тире: дефис с пробелами печатают и люди), «оставляет впечатление», «напоминает о», «может привести к», «невероятно»: обороты свежих моделей (GPT-4o, o3, GigaChat, YandexGPT, Qwen, DeepSeek, Claude), у каждой минимум четырёх семейств и не больше чем у 1% людей («X — это Y» у 3%). По одному бывают у человека, поэтому считаются не плотностью, а числом разных оборотов: первый почти бесплатен, второй и дальше дорого. В жанрах news, academic, legal и fiction не считаются. Замер: eval/MODERN-SLOP.md
347
+
348
+ **Обвязка чата:** предложение доработки («Хотите, чтобы я…», «Если нужно, могу сделать вариант покороче»), плейсхолдеры «[Ваше имя]», «Вот вариант:» и «Вариант 1», эмодзи вместо маркеров списка (❌ 1️⃣ 👉), подзаголовки Markdown «##» и строки целиком жирным: ответ чат-бота, вставленный вместе с упаковкой. У людей не встречается, но разметку человек тоже пишет, поэтому штраф за каждый след, а не приговор, как у артефактов копипасты. Ложное срабатывание: плейсхолдеры в шаблоне, который пользователь заполнит сам, не правятся (заполнить «[Имя клиента]» значит выдумать факт); балл такого текста не дойдёт до «чисто», и это нормально
349
+
346
350
  **Title Case в заголовках:** «Ранняя Жизнь и Образование»: два и больше нарицательных слова заголовка с заглавной буквы
347
351
 
348
352
  **Обрыв на полуслове:** последнее предложение не закончено, текст упёрся в лимит токенов
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  from dataclasses import dataclass, replace
15
15
 
16
16
  from .burstiness import RhythmStats, rhythm, rhythm_verdict
17
+ from .lexical import LexicalStats, lexical_stats
17
18
  from .markers import (
18
19
  MarkerHit,
19
20
  marker_verdict,
@@ -30,12 +31,14 @@ __all__ = [
30
31
  "RhythmStats",
31
32
  "MorphStats",
32
33
  "StructureStats",
34
+ "LexicalStats",
33
35
  "MarkerHit",
34
36
  "ScoreResult",
35
37
  "cleanliness_score",
36
38
  "rhythm",
37
39
  "morph_stats",
38
40
  "structure_stats",
41
+ "lexical_stats",
39
42
  "scan_hard_bans",
40
43
  "scan_markers",
41
44
  "mask_foreign",
@@ -52,6 +55,7 @@ class Report:
52
55
  rhythm: RhythmStats
53
56
  morph: MorphStats
54
57
  structure: StructureStats
58
+ lexical: LexicalStats
55
59
 
56
60
  @property
57
61
  def hard_ban_count(self) -> int:
@@ -70,6 +74,7 @@ class Report:
70
74
  "rhythm": self.rhythm.as_dict(),
71
75
  "morph": self.morph.as_dict(),
72
76
  "structure": self.structure.as_dict(),
77
+ "lexical": self.lexical.as_dict(),
73
78
  }
74
79
 
75
80
 
@@ -97,4 +102,5 @@ def analyze(text: str) -> Report:
97
102
  rhythm=stats,
98
103
  morph=morph_stats(prose),
99
104
  structure=structure_stats(prose),
105
+ lexical=lexical_stats(prose),
100
106
  )
@@ -19,6 +19,9 @@
19
19
  идиоматичны («с одной стороны») и обычно пересказывают число исходника. Они
20
20
  считаются, но не валят проверку.
21
21
 
22
+ Оговорки: «обычно», «примерно», «может», «от 5000». Снятая оговорка делает
23
+ из «обычно помогает» обещание «помогает»; она попадает в потери.
24
+
22
25
  Утверждения: слова-кванторы, которые звучат как пафос, а несут факт:
23
26
  «первый в России», «единственный», «впервые», «рекордный», «до сих пор».
24
27
  Срезать «первый в России сервис» до «сервис» значит исказить исходник, а
@@ -70,6 +73,16 @@ CLAIM_WORDS = {
70
73
  "старейший", "никогда", "никто", "навсегда", "беспрецедентный",
71
74
  }
72
75
  CLAIM_PHRASE_RE = re.compile(r"\bдо сих пор\b|\bв мире\b|\bв россии\b", re.IGNORECASE)
76
+ # Оговорки при факте: задают его точность («обычно», «примерно», «от 5000»).
77
+ # Скилл снимал их в 5 ответах из 25 (eval/MODERN-SKILL-CHECK.md), и строка в
78
+ # промпте не помогла, поэтому пропавшая оговорка попадает в список потерь.
79
+ # «Может показаться» это оговорка мнения, её снимать можно.
80
+ HEDGE_RE = re.compile(
81
+ r"\b(?:может\s+быть|мо(?:жет|гут)(?!\s+показаться)|обычно|как\s+правило|часто|иногда|"
82
+ r"примерно|приблизительно|около|почти|в\s+среднем|не\s+обязательно|не\s+всегда|"
83
+ r"вероятно|скорее\s+всего|по\s+оценкам|предположительно|ожида\w*|"
84
+ r"(?:от|до|свыше|более|менее|больше|меньше)(?=\s+\d))\b",
85
+ re.IGNORECASE)
73
86
  # Одна сущность под разными именами не считается новым фактом.
74
87
  ALIASES = {
75
88
  "ai": ("искусственный", "интеллект", "ии", "нейросеть", "модель"),
@@ -84,6 +97,7 @@ class Facts:
84
97
  soft: set[str] = field(default_factory=set) # "два", "половина"
85
98
  words: set[str] = field(default_factory=set) # все слова в нормальной форме, для алиасов
86
99
  claims: set[str] = field(default_factory=set) # "утверждение:первый", "утверждение:до сих пор"
100
+ hedges: set[str] = field(default_factory=set) # "оговорка:обычно", "оговорка:от"
87
101
 
88
102
 
89
103
  @dataclass
@@ -111,9 +125,20 @@ def _norm(word: str) -> str:
111
125
  return _MORPH.parse(low)[0].normal_form.replace("ё", "е")
112
126
 
113
127
 
128
+ # Начало строки и всё, что Markdown ставит перед первым словом: маркер списка
129
+ # («-», «*», «•», «1.», «2)»), цитата «>», решётки заголовка, «**» жирного,
130
+ # эмодзи-буллет. Без этого «- Говорить» или «**Тема**» давали «имя:говорить».
131
+ LINE_LEAD_RE = re.compile(r"^[ \t]*(?:(?:\d{1,3}[.)]|[^\w\s«\"'(\[])[ \t]*)*", re.MULTILINE)
132
+
133
+
114
134
  def _sentence_starts(text: str) -> set[int]:
115
135
  starts = {0}
116
- for m in re.finditer(r"[.!?…]\s+|^[>\-*]\s+|«|\n", text):
136
+ # После двоеточия, тире, открывающей кавычки и скобки заглавная тоже бывает
137
+ # у обычного слова («Совет: Не паникуйте», «(Например: …)»). Имя из словаря
138
+ # (теги Name, Geox, Orgn) и незнакомое словарю слово ловятся и там.
139
+ for m in re.finditer(r"[.!?…:;—–][*_]*\s+[*_«\"„“(]*|[«\"„“(]|\n", text):
140
+ starts.add(m.end())
141
+ for m in LINE_LEAD_RE.finditer(text):
117
142
  starts.add(m.end())
118
143
  return starts
119
144
 
@@ -143,6 +168,9 @@ def extract_facts(text: str) -> Facts:
143
168
  body = CODE_RE.sub(" ", URL_RE.sub(" ", text))
144
169
  for m in MONTH_RE.finditer(body):
145
170
  f.hard.add("месяц:" + m.group(1).lower())
171
+ for m in HEDGE_RE.finditer(body):
172
+ h = re.sub(r"\s+", " ", m.group(0).lower())
173
+ f.hedges.add("оговорка:" + ("может" if h in ("могут", "может") else "ожидается" if h.startswith("ожида") else h))
146
174
  for m in CLAIM_PHRASE_RE.finditer(body):
147
175
  f.claims.add("утверждение:" + m.group(0).lower().replace("ё", "е"))
148
176
  starts = _sentence_starts(body)
@@ -175,7 +203,7 @@ def _alias_covered(fact: str, before_words: set[str]) -> bool:
175
203
 
176
204
  def diff_facts(before: str, after: str) -> FactsDiff:
177
205
  b, a = extract_facts(before), extract_facts(after)
178
- lost = sorted(b.hard - a.hard) + sorted(b.claims - a.claims)
206
+ lost = sorted(b.hard - a.hard) + sorted(b.claims - a.claims) + sorted(b.hedges - a.hedges)
179
207
  added = sorted(x for x in a.hard - b.hard if not _alias_covered(x, b.words))
180
208
  return FactsDiff(lost=lost, added=added, kept=len(b.hard & a.hard),
181
209
  soft_added=sorted(a.soft - b.soft),
@@ -0,0 +1,86 @@
1
+ """Лексическое разнообразие: MATTR по словоформам на начале текста.
2
+
3
+ Свежие модели пишут лексически чище людей: слов из словаря сканера у них нет,
4
+ зато слова почти не повторяются. Человек в посте возвращается к «я», «он»,
5
+ «это», «было», к одному и тому же существительному, модель подбирает синоним.
6
+ Замер на LLMTrace и Пикабу (eval/MODERN-SLOP.md, раздел про разнообразие):
7
+ MATTR у людей 0.900 ± 0.042, у GPT-5.6 на 0.7 σ выше, у o3 и GigaChat-Max
8
+ больше чем на 1 σ.
9
+
10
+ Почему словоформы, а не леммы. По леммам сигнал чуть сильнее, но в браузере
11
+ морфологии нет, а сайт и расширение обязаны считать то же число, что scan.py.
12
+ Поэтому токенизация здесь нарочно примитивная и повторена в docs/scan.js байт
13
+ в байт: нижний регистр, ё → е, слово это кириллица с внутренними дефисами
14
+ («кто-то», «северо-запад»). Латиница и цифры не считаются: в русском тексте
15
+ это имена, бренды и числа, их разнообразие о стиле не говорит.
16
+
17
+ Почему только начало и почему не на коротком. MATTR почти не зависит от
18
+ длины, но длинный текст модель разбавляет повторами заголовков и списков:
19
+ первые LEX_MAX_TOKENS слов сравнивают тексты разной длины честно и одинаково
20
+ дёшево в браузере. На коротком тексте окон мало и число шумит: у людей на
21
+ 60-80 словах разброс 0.058 против 0.037 на 150+, и ниже LEX_MIN_TOKENS больше
22
+ половины срабатываний на людях приходилось именно на такие тексты. Поэтому
23
+ короче LEX_MIN_TOKENS словоформ признак не считается вовсе.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass
30
+
31
+ # Окно скользящего TTR, число первых слов, на которых он считается, и
32
+ # минимальная длина, с которой признак вообще считается. Подобраны на половине
33
+ # корпуса (eval/MODERN-SLOP.md): окно 40 и 200 слов разделяют людей и машины
34
+ # лучше, чем 150 (AUC для GPT-5.6 0.78 против 0.75), 300 почти не добавляет.
35
+ LEX_WINDOW = 40
36
+ LEX_MAX_TOKENS = 200
37
+ LEX_MIN_TOKENS = 100
38
+
39
+ _TOKEN_RE = re.compile(r"[а-яё]+(?:-[а-яё]+)*")
40
+
41
+
42
+ @dataclass
43
+ class LexicalStats:
44
+ tokens: int # кириллических словоформ в тексте всего
45
+ mattr: float # MATTR по первым LEX_MAX_TOKENS словоформам; 0.0, если слов меньше окна.
46
+ # Не округляется: штраф считается от точного числа, как в браузере
47
+
48
+ def as_dict(self) -> dict:
49
+ return self.__dict__.copy()
50
+
51
+
52
+ def lexical_tokens(text: str) -> list[str]:
53
+ """Словоформы для MATTR. Порт в docs/scan.js обязан дать тот же список."""
54
+ return [t.replace("ё", "е") for t in _TOKEN_RE.findall(text.lower())]
55
+
56
+
57
+ def mattr(tokens: list[str], window: int = LEX_WINDOW) -> float:
58
+ """Moving-average type-token ratio (Covington & McFall, 2010).
59
+
60
+ Среднее по всем окнам длины window доли разных словоформ. Сумма копится в
61
+ целых числах и делится один раз: так JS и Python получают одно и то же
62
+ число с плавающей точкой, без накопленной разницы округлений.
63
+ """
64
+ n = len(tokens)
65
+ if n < window:
66
+ return 0.0
67
+ counts: dict[str, int] = {}
68
+ for t in tokens[:window]:
69
+ counts[t] = counts.get(t, 0) + 1
70
+ distinct = len(counts)
71
+ total = distinct
72
+ for i in range(window, n):
73
+ new, old = tokens[i], tokens[i - window]
74
+ counts[new] = counts.get(new, 0) + 1
75
+ if counts[new] == 1:
76
+ distinct += 1
77
+ counts[old] -= 1
78
+ if counts[old] == 0:
79
+ distinct -= 1
80
+ total += distinct
81
+ return total / ((n - window + 1) * window)
82
+
83
+
84
+ def lexical_stats(text: str) -> LexicalStats:
85
+ toks = lexical_tokens(text)
86
+ return LexicalStats(tokens=len(toks), mattr=mattr(toks[:LEX_MAX_TOKENS]))
@@ -248,6 +248,68 @@ SCANNER: dict[str, list[str | tuple[str, str]]] = {
248
248
  ("мораль проста (последней фразой)",
249
249
  r"[.!?…]\s+мораль\s+(?:проста|такова)\b[^.!?]{0,60}[.!?]\s*$"),
250
250
  ],
251
+ # Почерк модели 2024-2026. Каталог выше собран на старом слопе («в
252
+ # современном мире», «играет ключевую роль»), и текст свежей модели
253
+ # получал 85-98 «чисто». Эти обороты добыты сравнением 2 110 машинных
254
+ # текстов (GPT-4o, o3, GigaChat-Max, YandexGPT-5, Qwen2.5, DeepSeek-R1,
255
+ # Llama-3.3, Gemma, Claude) с 1 900 человеческими (LLMTrace, Пикабу), см.
256
+ # eval/MODERN-SLOP.md. Каждый встречается у моделей минимум четырёх семейств
257
+ # и не чаще чем у 1% людей (кроме «X — это Y»: 3%, зато у Claude 53%).
258
+ # Поодиночке любой из них бывает у человека, поэтому в score они идут не
259
+ # плотностью, а числом РАЗНЫХ оборотов: первый почти бесплатен, дальше дорого.
260
+ "Почерк модели": [
261
+ ("подчёркивает/акцентирует", r"\bподч[её]ркива(?:ет|ют|я)\b|\bакцентир\w+"),
262
+ ("свидетельствует о", r"\bсвидетельству\w+\s+о\b"),
263
+ ("демонстрирует", r"\bдемонстрир\w+"),
264
+ ("это не просто/не только", r"\bэто\s+не\s+(?:просто|только)\b"),
265
+ ("что делает его", r"\bчто\s+дела(?:ет|ют)\s+(?:его|её|ее|их|это|эту|этот)\b"),
266
+ ("важность", r"\bважност\w+"),
267
+ ("заставляет задуматься", r"\b(?:заставля\w+|стоит|стоило|повод)\s+задуматься\b"),
268
+ ("стал символом", r"\bсимволом\b"),
269
+ ("стал настоящим открытием", r"\bнастоящ(?:им|ей|ими)\s+[а-яё]{4,}"),
270
+ ("важный шаг", r"\b(?:важн|значим|ключев|решающ|смел|уверенн)\w*\s+шаг\w*"),
271
+ ("представьте себе", r"\bпредставьте\b"),
272
+ ("заслуживает", r"\bзаслужива\w+"),
273
+ ("для тех, кто", r"\bдля\s+тех,?\s+кто\b"),
274
+ ("Это позволяет",
275
+ r"(?:^|[.!?]\s+)(?:это|такой\s+подход|всё\s+это|все\s+это)\s+"
276
+ r"(?:позволя|помога|дела|да[её]т|способству|созда|обеспечива|станов)\w*"),
277
+ ("вдохновляет", r"\bвдохнов\w+"),
278
+ ("незабываемый", r"\bнезабываем\w+"),
279
+ ("гармония", r"\bгармони\w+"),
280
+ ("X — это Y", r"[а-яё»)]\s+—\s+это\s"),
281
+ ("оставляет впечатление", r"\bоставля\w+\s+(?:\w+\s+)?(?:впечатлени|след|равнодушн)\w*"),
282
+ ("напоминает о", r"\bнапомина\w+\s+(?:нам\s+)?(?:о|об|что)\b"),
283
+ ("может привести к", r"\bможет\s+привести\s+к\b"),
284
+ ("невероятно", r"\bневероятн\w+"),
285
+ ],
286
+ # Обвязка ответа чат-бота, которая уезжает в текст при копировании: оферта
287
+ # доработки, плейсхолдеры, «Вариант 1», Markdown-разметка. У людей в
288
+ # LLMTrace и на Пикабу 0% (подзаголовок «##» 0,7%: вёрстка статей), у Claude
289
+ # 14-71% по признаку. Слабее «Артефактов копипасты»: разметку Markdown
290
+ # человек тоже пишет, поэтому штраф за признак, а не приговор.
291
+ # Строки ловятся через \n, а не ^/$: веб-движок работает без флага m.
292
+ "Обвязка чата": [
293
+ ("предложение доработки",
294
+ r"\b(?:хотите,?\s+чтобы\s+я|могу\s+также|могу\s+(?:подготовить|предложить|адаптировать|"
295
+ r"переписать|сделать\s+(?:вариант|версию))|могу\s+помочь\s+с)\b"
296
+ r"|\b(?:если\s+(?:нужно|хотите|надо)|при\s+желании)[^.\n]{0,25}(?:—|,|-)\s*(?:я\s+)?"
297
+ r"(?:могу|сделаю|подготовлю|напишу|адаптирую|доработаю)\b"
298
+ r"|\b(?:пришлите|скажите|уточните|дайте\s+знать)[^\n]{0,80}(?:—|,|и)\s*(?:я\s+)?(?:сразу\s+)?"
299
+ r"(?:подготовлю|доработаю|напишу|адаптирую|подстрою|переделаю)\b"),
300
+ ("плейсхолдер [Ваше имя]",
301
+ r"\[(?:ваш|ваше|ваша|имя|название|дата|компани|должност|указать|число|телефон|ссылк|"
302
+ r"город|продукт|причин|конкретн)[^\]\n]{0,60}\]"),
303
+ ("«Вот вариант:» / «Вариант 1»",
304
+ r"(?:^|\n)[ \t]*(?:\*\*)?(?:вот|ниже)\s+(?:несколько\s+)?(?:вариант|пример|шаблон|текст|"
305
+ r"черновик|готов|проверенн)\w*[^\n]{0,80}:"
306
+ r"|(?:^|\n)[ \t]*(?:\*\*|#+[ \t]*)?вариант\s+\d"),
307
+ ("эмодзи вместо маркеров списка", r"(?:^|\n)[ \t]*(?:[0-9]️?⃣|[❌✅✔➡→▶👉🔹🔸])"),
308
+ # Только подзаголовки разделов: одиночный «# Название» у человека норма
309
+ # (заголовок рассказа в фикстуре eval/corpus/literary).
310
+ ("markdown-подзаголовок ##", r"(?:^|\n)#{2,4}[ \t]+\S"),
311
+ ("жирный подзаголовок **…**", r"(?:^|\n)[ \t]*\*\*[^*\n]{2,80}\*\*:?[ \t]*(?=\n)"),
312
+ ],
251
313
  # Технические следы копирования из интерфейса чат-бота. Однозначные улики:
252
314
  # в человеческом тексте не встречаются.
253
315
  # Префикс "re:" = регулярное выражение, остальное — подстрока.
@@ -369,10 +431,14 @@ GENRE_MUTED_BANS: dict[str, set[str]] = {
369
431
  }
370
432
 
371
433
  GENRE_MUTED_CATEGORIES: dict[str, set[str]] = {
372
- "academic": {"Канцелярит", "Кальки", "Контекстуализаторы", "Формула-выводы"},
373
- "legal": {"Канцелярит", "Кальки", "Контекстуализаторы"},
374
- "fiction": {"Параллелизмы"},
375
- "news": {"Канцелярит", "Кальки", "Контекстуализаторы"},
434
+ # «Почерк модели» снят везде, кроме умолчания: «свидетельствует о»,
435
+ # «демонстрирует», «X — это Y» в новостях, науке и праве законная норма, а в
436
+ # художественной речи это реплики героев. Умолчание (блоги, посты,
437
+ # маркетинг) остаётся строгим: под него и собирался корпус.
438
+ "academic": {"Канцелярит", "Кальки", "Контекстуализаторы", "Формула-выводы", "Почерк модели"},
439
+ "legal": {"Канцелярит", "Кальки", "Контекстуализаторы", "Почерк модели"},
440
+ "fiction": {"Параллелизмы", "Почерк модели"},
441
+ "news": {"Канцелярит", "Кальки", "Контекстуализаторы", "Почерк модели"},
376
442
  }
377
443
 
378
444
  GENRES = ("marketing", "academic", "legal", "fiction", "news")
@@ -107,6 +107,7 @@ def scan(text: str, genre: str | None = None) -> dict:
107
107
  "rhythm": rep.rhythm.as_dict(),
108
108
  "morph": rep.morph.as_dict(),
109
109
  "structure": rep.structure.as_dict(),
110
+ "lexical": rep.lexical.as_dict(),
110
111
  }
111
112
 
112
113
 
@@ -11,9 +11,11 @@
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import math
14
15
  from dataclasses import dataclass, field
15
16
 
16
17
  from .burstiness import CV_HUMAN_TARGET, STACCATO_MIN_RUN
18
+ from .lexical import LEX_MIN_TOKENS
17
19
  from .markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES,
18
20
  effective_hard_bans, mute_by_genre)
19
21
  from .morphology import NV_TARGET
@@ -30,6 +32,21 @@ from .structure import (
30
32
  EM_DASH_NAME = "Длинное тире"
31
33
  COPY_PASTE_CATEGORY = "Артефакты копипасты"
32
34
 
35
+ # Почерк свежих моделей и обвязка чата (markers.py, eval/MODERN-SLOP.md).
36
+ # Считаются числом РАЗНЫХ оборотов, а не плотностью: плотность размывается на
37
+ # длинном тексте, а у современной модели два-три таких оборота на пост. Один
38
+ # оборот бывает и у человека (до 3%), поэтому первый стоит 3 балла, каждый
39
+ # следующий 10. Веса подобраны на половине корпуса и проверены на второй:
40
+ # пойманного слопа 53% → 67%, ложных тревог на людях +1 п.п.
41
+ SIGNATURE_CATEGORY = "Почерк модели"
42
+ SIGNATURE_FIRST = 3
43
+ SIGNATURE_NEXT = 10
44
+ SIGNATURE_MAX = 24
45
+ CHAT_WRAP_CATEGORY = "Обвязка чата"
46
+ CHAT_WRAP_EACH = 10
47
+ CHAT_WRAP_MAX = 20
48
+ DISTINCT_CATEGORIES = (SIGNATURE_CATEGORY, CHAT_WRAP_CATEGORY)
49
+
33
50
  # Полосы. Совпадают с порогами вмешательства из SKILL.md.
34
51
  BAND_CLEAN = 85 # ≥ — следы ИИ не мешают, не править
35
52
  BAND_EDIT = 60 # ≥ — точечная правка; < — полный рерайт
@@ -57,6 +74,24 @@ STERILE_MIN_WORDS = 100
57
74
  # них настоящие авторские обрывки.
58
75
  STACCATO_PENALTY = 8
59
76
  STACCATO_PENALTY_MAX = 14
77
+ # Лексическое разнообразие (lexical.py): слова почти не повторяются, модель
78
+ # подбирает синоним там, где человек сказал бы то же слово или «он». MATTR у
79
+ # людей 0.902 ± 0.037, порог 0.955 это +1.4 σ. Балл за каждую тысячную выше
80
+ # порога, потолок 15: ниже, чем у почерка модели, потому что признак
81
+ # статистический и на одном тексте шумит. Подобрано на половине корпуса и
82
+ # проверено на второй (eval/MODERN-SLOP.md): ловит GigaChat-Max и o3, а GPT-5.6
83
+ # не сдвигает, его тексты лежат внутри человеческого разброса.
84
+ #
85
+ # В новостях, научном и юридическом регистре штраф снят: плотный фактический
86
+ # текст разнообразен законно. На людях LLMTrace он срабатывал чаще всего на
87
+ # новостях (2,8% текстов против 0,6-0,9% у статей и отзывов), а на справочных
88
+ # текстах задевал людей почти так же часто, как машины, и ни одну машину не
89
+ # перевёл из «чисто». В художественном жанре оставлен: на рассказах LLMTrace
90
+ # машины срабатывают впятеро чаще людей.
91
+ LEX_THRESHOLD = 0.955
92
+ LEX_SLOPE = 1000
93
+ LEX_PENALTY_MAX = 15
94
+ LEX_MUTED_GENRES = frozenset({"news", "academic", "legal"})
60
95
  # Потолок штрафа за номинальность. Именованный, потому что браузерный сканер
61
96
  # морфологии не имеет и объявляет ровно эту величину как неизмеренную.
62
97
  NV_PENALTY_MAX = 8
@@ -130,13 +165,26 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
130
165
  penalties.append((f"артефакты копипасты: {copy_paste}", -pen))
131
166
 
132
167
  # 3. Мягкие маркеры (кроме копипасты) по плотности на 100 слов.
133
- soft = sum(h.count for h in markers if h.category != COPY_PASTE_CATEGORY)
168
+ soft = sum(h.count for h in markers
169
+ if h.category != COPY_PASTE_CATEGORY and h.category not in DISTINCT_CATEGORIES)
134
170
  if soft:
135
171
  pen = min(30, round(2 * _per100(soft, words)))
136
172
  if pen:
137
173
  score -= pen
138
174
  penalties.append((f"маркеры: {soft} ({_per100(soft, words):.1f}/100 слов)", -pen))
139
175
 
176
+ # 3б. Почерк модели и обвязка чата: по числу разных оборотов (см. константы).
177
+ sig = len({h.marker for h in markers if h.category == SIGNATURE_CATEGORY})
178
+ if sig:
179
+ pen = min(SIGNATURE_MAX, SIGNATURE_FIRST + SIGNATURE_NEXT * (sig - 1))
180
+ score -= pen
181
+ penalties.append((f"почерк модели: {sig} {_plural(sig, 'оборот', 'оборота', 'оборотов')}", -pen))
182
+ wrap = len({h.marker for h in markers if h.category == CHAT_WRAP_CATEGORY})
183
+ if wrap:
184
+ pen = min(CHAT_WRAP_MAX, CHAT_WRAP_EACH * wrap)
185
+ score -= pen
186
+ penalties.append((f"обвязка чата: {wrap} {_plural(wrap, 'след', 'следа', 'следов')}", -pen))
187
+
140
188
  # 4. Длинное тире по плотности с допуском ~2 на 100 слов: «—» штатно
141
189
  # используется в русском (Википедия, «это —», диапазоны). Штраф мягкий,
142
190
  # потому что на изданной прозе оно частая норма; сам бан держится на
@@ -166,6 +214,18 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
166
214
  f"рваная медитативность: {runs} {_plural(runs, 'цепочка', 'цепочки', 'цепочек')} "
167
215
  f"обрывков по {STACCATO_MIN_RUN}+ подряд (самая длинная {report.rhythm.staccato_max})", -pen))
168
216
 
217
+ # 5в. Лексическое разнообразие: считается только на тексте от LEX_MIN_TOKENS
218
+ # словоформ, на коротком MATTR шумит. floor, а не round: браузер обязан
219
+ # получить то же целое, а округление половин в JS и Python разное.
220
+ lex = report.lexical
221
+ if (genre not in LEX_MUTED_GENRES and lex.tokens >= LEX_MIN_TOKENS
222
+ and lex.mattr > LEX_THRESHOLD):
223
+ pen = min(LEX_PENALTY_MAX, math.floor((lex.mattr - LEX_THRESHOLD) * LEX_SLOPE))
224
+ if pen:
225
+ score -= pen
226
+ penalties.append((f"лексическое разнообразие (MATTR={lex.mattr:.3f}, "
227
+ f"порог {LEX_THRESHOLD})", -pen))
228
+
169
229
  # 6. Номинальность: сущ./глаг. выше цели 2.5 = канцелярит. Слабый сигнал и
170
230
  # главный источник ложных срабатываний (энциклопедический/юр. регистр
171
231
  # легитимно номинален), поэтому штраф мягкий и низко ограничен.
@@ -199,7 +259,7 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
199
259
  # Условие ровно то, что измерялось: ноль банов и ноль маркеров. Ритм,
200
260
  # номинальность и структура сюда не входят, иначе текст с минусом за ровный
201
261
  # ритм терял бы заметку, хотя по лексике он как раз стерилен.
202
- if not (hard_phrase or copy_paste or soft) and words >= STERILE_MIN_WORDS:
262
+ if not (hard_phrase or copy_paste or soft or sig or wrap) and words >= STERILE_MIN_WORDS:
203
263
  share = next((s for limit, s in HUMAN_ZERO_SHARE if words < limit),
204
264
  HUMAN_ZERO_SHARE[-1][1])
205
265
  notes.append(
@@ -24,6 +24,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
24
  try:
25
25
  from humanizer_metrics import analyze, cleanliness_score, diff_facts, facts_verdict
26
26
  from humanizer_metrics.burstiness import rhythm_verdict
27
+ from humanizer_metrics.lexical import LEX_MIN_TOKENS
28
+ from humanizer_metrics.score import LEX_MUTED_GENRES, LEX_THRESHOLD
27
29
  from humanizer_metrics.markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES,
28
30
  GENRES, effective_hard_bans, marker_verdict,
29
31
  mute_by_genre)
@@ -185,6 +187,14 @@ def main() -> int:
185
187
  f"(min {rep.rhythm.min_len} / max {rep.rhythm.max_len}), CV: {rep.rhythm.cv_len}")
186
188
  print(f" многоточий: {rep.rhythm.ellipsis}, скобок: {rep.rhythm.parentheses}, "
187
189
  f"вопросов: {rep.rhythm.questions}")
190
+ if rep.lexical.tokens >= LEX_MIN_TOKENS:
191
+ rule = (f"в жанре {genre} не штрафуется" if genre in LEX_MUTED_GENRES
192
+ else f"штраф выше {LEX_THRESHOLD}")
193
+ print(f" лексическое разнообразие: MATTR {rep.lexical.mattr:.3f} "
194
+ f"(у людей обычно 0.90 ± 0.04, {rule})")
195
+ else:
196
+ print(f" лексическое разнообразие: не считается, словоформ {rep.lexical.tokens} "
197
+ f"(нужно от {LEX_MIN_TOKENS})")
188
198
  print()
189
199
 
190
200
  print("Морфология:")