humanizer-ru 3.27.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 +1 -1
- package/README.md +18 -4
- package/package.json +1 -1
- package/skills/humanizer-ru/SKILL.md +29 -14
- package/skills/humanizer-ru/references/catalog.md +5 -1
- package/skills/humanizer-ru/scripts/humanizer_metrics/__init__.py +6 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/facts.py +30 -2
- package/skills/humanizer-ru/scripts/humanizer_metrics/lexical.py +86 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/markers.py +70 -4
- package/skills/humanizer-ru/scripts/humanizer_metrics/mcp_server.py +1 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/score.py +66 -3
- package/skills/humanizer-ru/scripts/scan.py +19 -2
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)
|
|
8
|
-
[](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
|
|
9
9
|
[](https://github.com/ilyautov/humanizer-ru/stargazers)
|
|
10
10
|
[](https://skills.sh/ilyautov/humanizer-ru/humanizer-ru)
|
|
11
11
|
[](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)
|
|
8
|
-
[](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
|
|
9
9
|
[](https://github.com/ilyautov/humanizer-ru/stargazers)
|
|
10
10
|
[](https://skills.sh/ilyautov/humanizer-ru/humanizer-ru)
|
|
11
11
|
[](https://www.npmjs.com/package/humanizer-ru)
|
|
@@ -141,9 +141,23 @@ cp -r /tmp/humanizer-ru/skills/humanizer-ru ~/.claude/skills/
|
|
|
141
141
|
```
|
|
142
142
|
|
|
143
143
|
Копируйте папку целиком, а не один SKILL.md: вместе со скиллом едет
|
|
144
|
-
детерминированный сканер `scripts/scan.py` (машинная половина режима
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
детерминированный сканер `scripts/scan.py` (машинная половина режима «Аудит»).
|
|
145
|
+
Ему нужны пакеты `razdel` и `pymorphy3`. Без них скилл проводит аудит вручную
|
|
146
|
+
и обязан сказать об этом; балла «Чистота: N/100» вы не увидите.
|
|
147
|
+
|
|
148
|
+
Как поставить зависимости. На macOS с Python из Homebrew и на Debian/Ubuntu
|
|
149
|
+
голый `pip install` падает с `externally-managed-environment` (PEP 668),
|
|
150
|
+
поэтому любой из трёх способов:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uvx ru-humanizer файл.txt # без установки, нужен uv
|
|
154
|
+
pipx install ru-humanizer # команда ru-humanizer в PATH
|
|
155
|
+
python3 -m venv ~/.humanizer-ru && ~/.humanizer-ru/bin/pip install razdel pymorphy3
|
|
156
|
+
~/.humanizer-ru/bin/python ~/.claude/skills/humanizer-ru/scripts/scan.py файл.txt
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Проверка, что сканер живой: `python3 ~/.claude/skills/humanizer-ru/scripts/scan.py --help`
|
|
160
|
+
без строки `[ошибка]`.
|
|
147
161
|
|
|
148
162
|
### 4. Codex CLI (OpenAI)
|
|
149
163
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "humanizer-ru",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
7
|
+
# Humanizer-RU v3.29.0
|
|
8
8
|
|
|
9
9
|
Ты редактор. Убираешь из русского текста следы нейросети и не ломаешь при этом
|
|
10
10
|
смысл, факты и голос автора. Оба обещания равноправны: текст, который стал «чище»
|
|
@@ -46,12 +46,18 @@ license: MIT
|
|
|
46
46
|
|
|
47
47
|
## Шаг 1. Диагностика
|
|
48
48
|
|
|
49
|
-
**Сканер.** Если доступны
|
|
50
|
-
|
|
49
|
+
**Сканер.** Если доступны команды, первый вызов инструмента в редактуре это
|
|
50
|
+
сканер, до каталога и до правки: `python3 <папка скилла>/scripts/scan.py файл.txt` или
|
|
51
51
|
`echo "текст" | python3 <папка скилла>/scripts/scan.py -`. Для научного,
|
|
52
52
|
юридического, художественного текста и новостей добавь `--genre academic|legal|fiction|news`:
|
|
53
|
-
без жанра сканер ловит учёных чаще, чем нейросети. Запомни балл «было».
|
|
54
|
-
|
|
53
|
+
без жанра сканер ловит учёных чаще, чем нейросети. Запомни балл «было». Если
|
|
54
|
+
`scan.py` упал с «не хватает зависимостей», а `uvx` есть в PATH, запусти то же
|
|
55
|
+
через `uvx ru-humanizer` с теми же аргументами (`-` для stdin, `--genre`, `--before`):
|
|
56
|
+
зависимости он поставит сам. Упал и он или команд нет вовсе: скажи пользователю
|
|
57
|
+
одной строкой, что сканер не запустился и балла не будет (как поставить, написано
|
|
58
|
+
в его ошибке и в README), и работай вручную.
|
|
59
|
+
Молча пропускать сканер нельзя: без балла правка не измерима, а пользователь
|
|
60
|
+
месяцами не узнает, что половина скилла не работает. Балл на глаз не придумывай.
|
|
55
61
|
|
|
56
62
|
**Чтение целиком.** Определи задачу текста, регистр (маркетинг, экспертный, деловой,
|
|
57
63
|
научный, документация, юридический, художественный) и позицию, которую автор уже
|
|
@@ -73,9 +79,9 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
73
79
|
|
|
74
80
|
| Группа | Что это | Когда править |
|
|
75
81
|
|---|---|---|
|
|
76
|
-
| A | Жёсткие запреты (таблица ниже), пустые открытия, канцелярит (отглагольные существительные «осуществление», «внедрение», «реализация», «обеспечение», «взаимодействие», «оптимизация», «функционирование» и «в рамках», «в целях» → глагол или предлог), артефакты чатбота («Давайте разберёмся», «Надеюсь, помог»), негативные параллелизмы,
|
|
77
|
-
| B | Размытые авторитеты («эксперты считают»), кальки и пунктуационные кальки, «является» через предложение, водянистость, «определённый»/«соответствующий», ровные абзацы одной длины и гладкие переходы, мораль в финале, портретный ввод героя, эмодзи-декор, translationese | Везде, кроме юридических текстов |
|
|
78
|
-
| C | Раздувание значимости, формульные выводы, правило трёх (искусственные триады синонимов), карусель синонимов, частые тире,
|
|
82
|
+
| A | Жёсткие запреты (таблица ниже), пустые открытия, канцелярит (отглагольные существительные «осуществление», «внедрение», «реализация», «обеспечение», «взаимодействие», «оптимизация», «функционирование» и «в рамках», «в целях» → глагол или предлог), артефакты чатбота («Давайте разберёмся», «Надеюсь, помог»), обвязка чата (вступление «Вот вариант:», предложение доработки, советы после самого текста снимаются; у «Вариант 1/2» снимается метка, а все варианты остаются, если пользователь не просил выбрать; плейсхолдер «[Имя]» не заполняй, перечисли пользователю), негативные параллелизмы, оговорки мнения («может показаться», «в некотором смысле»), псевдо-терапия («ты имеешь право так чувствовать») | Всегда, в любом регистре кроме цитат |
|
|
83
|
+
| B | Размытые авторитеты («эксперты считают»), кальки и пунктуационные кальки, «является» через предложение, водянистость, «определённый»/«соответствующий», ровные абзацы одной длины и гладкие переходы, мораль в финале, портретный ввод героя, эмодзи-декор, translationese; в постах и письмах подзаголовки «##» и строки целиком жирным становятся обычными строками | Везде, кроме юридических текстов |
|
|
84
|
+
| C | Раздувание значимости, формульные выводы, правило трёх (искусственные триады синонимов), карусель синонимов, частые тире, отсутствие идиом, контр-вопросы («Зачем? Потому что.»), рваная медитативность («Точно. Отдельно.»), почерк модели (определение через тире, «стал настоящим открытием», «вдохновляет», «гармония», «напоминает о», «подчёркивает») | Маркетинг и экспертный текст |
|
|
79
85
|
| D | Болд, кавычки, списки, типографика | По контексту |
|
|
80
86
|
|
|
81
87
|
Полные описания и примеры «было и стало» по номерам паттернов в каталоге.
|
|
@@ -109,7 +115,7 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
109
115
|
| «Играет важную/ключевую роль» | Оставь только то, ПОЧЕМУ важно, если это есть в тексте |
|
|
110
116
|
| «Можно с уверенностью сказать» | Скажи без преамбулы |
|
|
111
117
|
| «Подводя итог», «Таким образом,» в начале вывода | Убери или начни вывод с действия. «Устроен таким образом, что» не маркер |
|
|
112
|
-
| Длинное тире
|
|
118
|
+
| Длинное тире «—», короткое «–» вне числовых диапазонов и дефис с пробелами в роли тире | Запятая, двоеточие, точка или перестройка. Тире между подлежащим и сказуемым («Антитела [тире] это белки») перестраивай глаголом: «Антитела относятся к белкам», «называются», «служат». Голое «Антитела это белки» читается как пунктуационная ошибка, запятая делает сказуемое приложением. Убирается всегда; оставить тире можно только по явной просьбе пользователя |
|
|
113
119
|
| «От X до Y» для несвязанных понятий | Перечисли конкретно или убери |
|
|
114
120
|
| «Погрузимся в...», «Давайте посмотрим поближе», «И вот здесь начинается самое интересное» | Удали анонс, сразу пиши суть |
|
|
115
121
|
| «Раскрыть потенциал», «Вывести на новый уровень», «Открывает новые горизонты/перспективы/возможности» | Конкретный результат, если он назван в тексте; иначе удали |
|
|
@@ -140,6 +146,10 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
140
146
|
|
|
141
147
|
> **Факт-замок (приоритет над любым паттерном).** Числа, даты, имена, названия,
|
|
142
148
|
> проценты, единицы, условия и оговорки исходника переносятся без искажений.
|
|
149
|
+
> Оговорка при факте («может быть», «часто», «обычно», «примерно», «от 5000»,
|
|
150
|
+
> «не обязательно», «ожидаем») задаёт его точность и не снимается; снимаются
|
|
151
|
+
> только оговорки мнения из группы A. Сканер с `--before исходник.txt` печатает
|
|
152
|
+
> снятую оговорку строкой «потеряно: оговорка:…»; стояла она при факте, верни её.
|
|
143
153
|
> Добавлять факты, которых в исходнике нет (цифры, исследования, кейсы, «у себя
|
|
144
154
|
> в команде вижу»), запрещено. В исходнике нет конкретики, а оборот пустой?
|
|
145
155
|
> Удали оборот или спроси пользователя, чем наполнить. Выдуманная цифра хуже
|
|
@@ -179,17 +189,22 @@ SKILL.md, без твоих рассуждений и без списка изм
|
|
|
179
189
|
случайный читатель в ленте, и по тому же брифу: не осталось ли конструкций из
|
|
180
190
|
таблицы запретов, не повторяются ли в твоих вставках одинаковые концовки и
|
|
181
191
|
подводки, не выровнялись ли абзацы под одну длину. Отдельно найди в чистовике
|
|
182
|
-
|
|
183
|
-
самая частая недоделка.
|
|
192
|
+
«—», «–» и « - »: их не должно остаться нигде, кроме числовых диапазонов, и это
|
|
193
|
+
самая частая недоделка. Без сканера ищи там же «не просто», «не только», «это не»
|
|
194
|
+
и английские слова внутри русских фраз. Если сканер был доступен, прогони
|
|
195
|
+
результат ещё раз.
|
|
184
196
|
Правка закончена, когда отмеченные недостатки сняты, а новые изменения не дают
|
|
185
197
|
читателю понятной пользы. Замечания остались после второго круга? Верни текст с
|
|
186
198
|
их списком, третий круг сглаживает содержание ради балла.
|
|
187
199
|
|
|
188
200
|
## Отчёт
|
|
189
201
|
|
|
190
|
-
По умолчанию верни итоговый текст. Если сканер запускался, добавь
|
|
191
|
-
«Чистота: было N, стало M» и одну-две фразы, что именно
|
|
192
|
-
|
|
202
|
+
По умолчанию верни итоговый текст. Если сканер запускался, добавь после текста
|
|
203
|
+
строку «Чистота: было N, стало M» и одну-две фразы, что именно снято; просили
|
|
204
|
+
только текст, строки нет. Финальное сообщение начинается с первой строки текста:
|
|
205
|
+
перед ним нет балла, вердикта сканера, «вот итоговый текст» и заметок о файлах
|
|
206
|
+
и правах, а отчёт после текста пишется на языке пользователя. Объяснение всех изменений
|
|
207
|
+
давай по запросу. Балл это правила сканера, а не вероятность ИИ и не
|
|
193
208
|
вердикт об авторстве; скилл не обходит детекторы и антиплагиат, не вставляет
|
|
194
209
|
опечатки и не подменяет буквы. Если текст уже хорош, скажи об этом и не правь
|
|
195
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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")
|
|
@@ -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,27 @@ 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"})
|
|
95
|
+
# Потолок штрафа за номинальность. Именованный, потому что браузерный сканер
|
|
96
|
+
# морфологии не имеет и объявляет ровно эту величину как неизмеренную.
|
|
97
|
+
NV_PENALTY_MAX = 8
|
|
60
98
|
|
|
61
99
|
|
|
62
100
|
@dataclass
|
|
@@ -127,13 +165,26 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
|
|
|
127
165
|
penalties.append((f"артефакты копипасты: {copy_paste}", -pen))
|
|
128
166
|
|
|
129
167
|
# 3. Мягкие маркеры (кроме копипасты) по плотности на 100 слов.
|
|
130
|
-
soft = sum(h.count for h in markers
|
|
168
|
+
soft = sum(h.count for h in markers
|
|
169
|
+
if h.category != COPY_PASTE_CATEGORY and h.category not in DISTINCT_CATEGORIES)
|
|
131
170
|
if soft:
|
|
132
171
|
pen = min(30, round(2 * _per100(soft, words)))
|
|
133
172
|
if pen:
|
|
134
173
|
score -= pen
|
|
135
174
|
penalties.append((f"маркеры: {soft} ({_per100(soft, words):.1f}/100 слов)", -pen))
|
|
136
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
|
+
|
|
137
188
|
# 4. Длинное тире по плотности с допуском ~2 на 100 слов: «—» штатно
|
|
138
189
|
# используется в русском (Википедия, «это —», диапазоны). Штраф мягкий,
|
|
139
190
|
# потому что на изданной прозе оно частая норма; сам бан держится на
|
|
@@ -163,12 +214,24 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
|
|
|
163
214
|
f"рваная медитативность: {runs} {_plural(runs, 'цепочка', 'цепочки', 'цепочек')} "
|
|
164
215
|
f"обрывков по {STACCATO_MIN_RUN}+ подряд (самая длинная {report.rhythm.staccato_max})", -pen))
|
|
165
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
|
+
|
|
166
229
|
# 6. Номинальность: сущ./глаг. выше цели 2.5 = канцелярит. Слабый сигнал и
|
|
167
230
|
# главный источник ложных срабатываний (энциклопедический/юр. регистр
|
|
168
231
|
# легитимно номинален), поэтому штраф мягкий и низко ограничен.
|
|
169
232
|
nv = report.morph.noun_verb_ratio
|
|
170
233
|
if nv > NV_TARGET:
|
|
171
|
-
pen = min(
|
|
234
|
+
pen = min(NV_PENALTY_MAX, round((nv - NV_TARGET) / 0.5 * 3))
|
|
172
235
|
if pen:
|
|
173
236
|
score -= pen
|
|
174
237
|
penalties.append((f"номинальность (сущ./глаг.={nv}, цель ≤{NV_TARGET})", -pen))
|
|
@@ -196,7 +259,7 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
|
|
|
196
259
|
# Условие ровно то, что измерялось: ноль банов и ноль маркеров. Ритм,
|
|
197
260
|
# номинальность и структура сюда не входят, иначе текст с минусом за ровный
|
|
198
261
|
# ритм терял бы заметку, хотя по лексике он как раз стерилен.
|
|
199
|
-
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:
|
|
200
263
|
share = next((s for limit, s in HUMAN_ZERO_SHARE if words < limit),
|
|
201
264
|
HUMAN_ZERO_SHARE[-1][1])
|
|
202
265
|
notes.append(
|
|
@@ -24,14 +24,23 @@ 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)
|
|
30
32
|
from humanizer_metrics.morphology import morph_verdict
|
|
31
33
|
from humanizer_metrics.structure import structure_verdict
|
|
32
34
|
except ImportError as exc:
|
|
33
|
-
print(f"[ошибка] не хватает зависимостей сканера ({exc.name})
|
|
34
|
-
"Поставьте один
|
|
35
|
+
print(f"[ошибка] не хватает зависимостей сканера ({exc.name}); балла не будет.\n"
|
|
36
|
+
"Поставьте один раз любым способом:\n"
|
|
37
|
+
" uvx ru-humanizer файл.txt (без установки, нужен uv)\n"
|
|
38
|
+
" pipx install ru-humanizer (даёт команду ru-humanizer)\n"
|
|
39
|
+
" python3 -m venv ~/.humanizer-ru && ~/.humanizer-ru/bin/pip install razdel pymorphy3\n"
|
|
40
|
+
" и запускайте ~/.humanizer-ru/bin/python scan.py файл.txt\n"
|
|
41
|
+
"Голый pip install на macOS с Homebrew и на Debian/Ubuntu падает с\n"
|
|
42
|
+
"externally-managed-environment (PEP 668): системный Python закрыт для pip.",
|
|
43
|
+
file=sys.stderr)
|
|
35
44
|
raise SystemExit(2)
|
|
36
45
|
|
|
37
46
|
|
|
@@ -178,6 +187,14 @@ def main() -> int:
|
|
|
178
187
|
f"(min {rep.rhythm.min_len} / max {rep.rhythm.max_len}), CV: {rep.rhythm.cv_len}")
|
|
179
188
|
print(f" многоточий: {rep.rhythm.ellipsis}, скобок: {rep.rhythm.parentheses}, "
|
|
180
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})")
|
|
181
198
|
print()
|
|
182
199
|
|
|
183
200
|
print("Морфология:")
|