humanizer-ru 3.25.0 → 3.27.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 +29 -1
- package/README.md +32 -2
- package/package.json +1 -1
- package/skills/humanizer-ru/SKILL.md +27 -11
- package/skills/humanizer-ru/references/catalog.md +1 -1
- package/skills/humanizer-ru/references/review.md +95 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/burstiness.py +40 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/mcp_server.py +254 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/score.py +27 -1
- package/skills/humanizer-ru/scripts/scan.py +5 -0
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)
|
|
@@ -172,6 +172,34 @@ cp -r humanizer-ru/skills/humanizer-ru ~/.agents/skills/
|
|
|
172
172
|
|
|
173
173
|
dsh scans `~/.agents/skills` and `~/.dsh/skills` on its own, no restart needed. It ignores the `allowed-tools` frontmatter key and resolves `references/` relative to the skill folder, so the catalog and the edit log open the same way as in Claude Code.
|
|
174
174
|
|
|
175
|
+
### 7. Scanner on its own: pip, MCP server, GitHub Action
|
|
176
|
+
|
|
177
|
+
The scanner also ships without the skill, as the [`ru-humanizer`](https://pypi.org/project/ru-humanizer/) package on PyPI (the `humanizer-ru` name on PyPI is taken). Same `scan.py`, as a command:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pip install ru-humanizer
|
|
181
|
+
ru-humanizer article.md --genre academic
|
|
182
|
+
ru-humanizer edited.md --before original.md
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The same package runs an MCP server with two tools: `scan_text` returns the score, penalties, hard bans and markers with positions; `compare_texts` reports "was N, now M" plus the fact lock between source and edit. Works with Claude Desktop, Cursor and any MCP client; in Claude Code it is one command:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
claude mcp add humanizer-ru -- uvx ru-humanizer mcp
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
In CI the scanner is a GitHub Action: a cleanliness gate over Markdown and text files that fails below the threshold.
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
- uses: ilyautov/humanizer-ru@main
|
|
195
|
+
with:
|
|
196
|
+
files: "docs/**/*.md"
|
|
197
|
+
genre: marketing
|
|
198
|
+
min-score: 60
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
One-time publishing setup for PyPI and the MCP registry lives in `PUBLISHING.md`.
|
|
202
|
+
|
|
175
203
|
## Modes
|
|
176
204
|
|
|
177
205
|
- **Edit** (default): scanner diagnosis, local fixes by priority, verification against the source, "was N, now M" report.
|
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)
|
|
@@ -41,6 +41,8 @@ Claude.ai, DeepSeek Harness и раскатка на команду в [разд
|
|
|
41
41
|
|
|
42
42
|
📖 **Документация и разборы:** [humanizer-ru.aifrontier.tech](https://humanizer-ru.aifrontier.tech/): [работают ли AI-детекторы на русском](https://humanizer-ru.aifrontier.tech/ai-detektory-na-russkom.html), [64 признака AI-текста](https://humanizer-ru.aifrontier.tech/52-priznaka-ai-teksta.html), [антиплагиат и нейросеть](https://humanizer-ru.aifrontier.tech/antiplagiat-i-neyroset.html).
|
|
43
43
|
|
|
44
|
+
🧰 **Остальные инструменты:** MCP-серверы к кабинетам Wildberries, Ozon, Яндекс Маркета и Авито, пять серверов к hh.ru, VK, Диадоку, СБИС и Честному знаку, 34 скилла для малого бизнеса, совет мыслителей с дословной проверкой цитат. Все одним списком: [ilyautov.github.io](https://ilyautov.github.io/).
|
|
45
|
+
|
|
44
46
|
## Зачем это нужно
|
|
45
47
|
|
|
46
48
|
Английский [humanizer](https://github.com/blader/humanizer) для русского текста не работает. У русских AI-маркеров своя физика: канцелярит («осуществление внедрения»), кальки с английского синтаксиса («стоит отметить, что»), отсутствующие частицы («же», «ведь», «вот»), которыми живой русский дышит. С английскими паттернами это не пересекается вообще.
|
|
@@ -214,6 +216,34 @@ cp -r humanizer-ru/skills/humanizer-ru ~/.agents/skills/
|
|
|
214
216
|
|
|
215
217
|
dsh сканирует `~/.agents/skills` и `~/.dsh/skills` сам, перезапуск не нужен. Ключ `allowed-tools` из фронтматтера dsh не читает, а `references/` разрешает относительно папки скилла, так что каталог и журнал правок открываются как в Claude Code.
|
|
216
218
|
|
|
219
|
+
### 7. Сканер отдельно: pip, MCP-сервер и GitHub Action
|
|
220
|
+
|
|
221
|
+
Сканер живёт и без скилла, пакетом [`ru-humanizer`](https://pypi.org/project/ru-humanizer/) на PyPI (имя `humanizer-ru` на PyPI занято). Это тот же `scan.py`, только командой:
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
pip install ru-humanizer
|
|
225
|
+
ru-humanizer статья.md --genre academic
|
|
226
|
+
ru-humanizer чистовик.md --before исходник.md
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Тот же пакет поднимает MCP-сервер с двумя инструментами: `scan_text` даёт балл, штрафы, запреты и маркеры с позициями, `compare_texts` считает «было и стало» и факт-замок между исходником и правкой. Подключается к Claude Desktop, Cursor и любому клиенту MCP; в Claude Code одной командой:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
claude mcp add humanizer-ru -- uvx ru-humanizer mcp
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
В CI сканер работает как GitHub Action: гейт на балл чистоты по Markdown и текстовым файлам, шаг падает ниже порога.
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
- uses: ilyautov/humanizer-ru@main
|
|
239
|
+
with:
|
|
240
|
+
files: "docs/**/*.md"
|
|
241
|
+
genre: marketing
|
|
242
|
+
min-score: 60
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Порядок разовой настройки публикации на PyPI и в реестре MCP описан в `PUBLISHING.md`.
|
|
246
|
+
|
|
217
247
|
## Использование
|
|
218
248
|
|
|
219
249
|
Попросите Claude по-русски:
|
|
@@ -381,4 +411,4 @@ MIT: используйте свободно, форкайте, дорабаты
|
|
|
381
411
|
- [**hefest**](https://github.com/ilyautov/hefest): химическая безопасность завода, целиком офлайн
|
|
382
412
|
- [**cordon**](https://github.com/ilyautov/cordon): детерминированный слой между недоверенным контентом и действиями агента
|
|
383
413
|
|
|
384
|
-
Все
|
|
414
|
+
Все проекты одним списком, разобранные по назначению: [ilyautov.github.io](https://ilyautov.github.io/). Исходники: [github.com/ilyautov](https://github.com/ilyautov). Пригодилось, поставьте звезду: по ней это находят другие.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "humanizer-ru",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.27.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.27.0
|
|
8
8
|
|
|
9
9
|
Ты редактор. Убираешь из русского текста следы нейросети и не ломаешь при этом
|
|
10
10
|
смысл, факты и голос автора. Оба обещания равноправны: текст, который стал «чище»
|
|
@@ -123,7 +123,9 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
123
123
|
Меняй найденное место и только ту часть окружения, без которой рвётся связность.
|
|
124
124
|
Сначала сними жёсткие запреты, затем пройди по разметке от группы A к D. При
|
|
125
125
|
сокращении сохрани содержимое обеих частей, если они сообщают разное. У пустой
|
|
126
|
-
риторической подводки убирается сама подводка.
|
|
126
|
+
риторической подводки убирается сама подводка. Проверка на переносимость: если
|
|
127
|
+
предложение без правки встанет в текст другой компании или другого автора, это
|
|
128
|
+
вода, снимай целиком. У содержательного, но туманного
|
|
127
129
|
утверждения упрощается формулировка с сохранением его ограниченности: «может
|
|
128
130
|
уменьшить число возвратов» после правки остаётся возможностью, а не обещанием.
|
|
129
131
|
|
|
@@ -141,7 +143,12 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
141
143
|
> Добавлять факты, которых в исходнике нет (цифры, исследования, кейсы, «у себя
|
|
142
144
|
> в команде вижу»), запрещено. В исходнике нет конкретики, а оборот пустой?
|
|
143
145
|
> Удали оборот или спроси пользователя, чем наполнить. Выдуманная цифра хуже
|
|
144
|
-
> канцелярита: канцелярит палит стиль, выдумка делает текст ложью.
|
|
146
|
+
> канцелярита: канцелярит палит стиль, выдумка делает текст ложью. Замок держит
|
|
147
|
+
> и соединительную ткань: длительность («за несколько кварталов»), причину
|
|
148
|
+
> («именно после этого тикетов стало меньше»), реакцию людей («самая
|
|
149
|
+
> востребованная функция»). Слов-маркеров в них нет, сканер их не видит, а
|
|
150
|
+
> читатель принимает за факт из источника. Перед каждой такой связкой проверь,
|
|
151
|
+
> утверждает ли исходник саму связь, а не только два факта рядом.
|
|
145
152
|
|
|
146
153
|
## Шаг 3. Сверка с исходником
|
|
147
154
|
|
|
@@ -152,7 +159,11 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
152
159
|
- причина и последовательность: соседство фактов остаётся соседством, а не выводом;
|
|
153
160
|
- числа вместе с единицами, выборкой, условиями и предметом сравнения;
|
|
154
161
|
- авторское суждение, личный опыт и объяснение механизма ровно в объёме источника;
|
|
155
|
-
- практическая функция: действие читателя, предложение, ограничение, срок,
|
|
162
|
+
- практическая функция: действие читателя, предложение, ограничение, срок, контакт;
|
|
163
|
+
- рамка: когда в тексте названы клиент, партнёр или человек, сравни, как они
|
|
164
|
+
выглядят до и после. Снятая пустая подводка порой была единственным смягчением,
|
|
165
|
+
и без неё кейс превращается в перечень чужих провалов. Чинится порядком, а не
|
|
166
|
+
возвратом оборота: сначала что сделали, потом в прошедшем времени что не ладилось.
|
|
156
167
|
|
|
157
168
|
Упоминание варианта не делает его единственным, перечисление не становится
|
|
158
169
|
исчерпывающим, «если» не превращается в «только если». Если источник сам задаёт
|
|
@@ -161,13 +172,18 @@ JOIN (убрать дублирование соседних фраз), SPLIT (
|
|
|
161
172
|
исходный объём; при сомнении оставь исходную фразу. Удалённое содержательное
|
|
162
173
|
утверждение восстанови. Цитаты, код, ссылки, имена и идентификаторы точны.
|
|
163
174
|
|
|
164
|
-
Затем
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
и
|
|
169
|
-
|
|
170
|
-
|
|
175
|
+
Затем чистовик вычитывает не автор правки. Есть субагенты? Запусти свежего по
|
|
176
|
+
[references/review.md](references/review.md): ему дают исходник, чистовик и этот
|
|
177
|
+
SKILL.md, без твоих рассуждений и без списка изменений, иначе проверяющий
|
|
178
|
+
становится сговорчивым. Субагентов нет? Перечитай чистовик сам, холодно, как
|
|
179
|
+
случайный читатель в ленте, и по тому же брифу: не осталось ли конструкций из
|
|
180
|
+
таблицы запретов, не повторяются ли в твоих вставках одинаковые концовки и
|
|
181
|
+
подводки, не выровнялись ли абзацы под одну длину. Отдельно найди в чистовике
|
|
182
|
+
символы «—» и «–»: их не должно остаться нигде, кроме числовых диапазонов, и это
|
|
183
|
+
самая частая недоделка. Если сканер был доступен, прогони результат ещё раз.
|
|
184
|
+
Правка закончена, когда отмеченные недостатки сняты, а новые изменения не дают
|
|
185
|
+
читателю понятной пользы. Замечания остались после второго круга? Верни текст с
|
|
186
|
+
их списком, третий круг сглаживает содержание ради балла.
|
|
171
187
|
|
|
172
188
|
## Отчёт
|
|
173
189
|
|
|
@@ -236,7 +236,7 @@ davinci-003). Метод и полные таблицы: `eval/AINL-CALIBRATION.
|
|
|
236
236
|
|
|
237
237
|
Это новейшие паттерны, которые проявились в Claude/GPT/Gemini 2025-2026 как ответ на критику «текст звучит сухо». Модели стали имитировать «глубокомысленность», «вовлечённость», «эмпатию». Имитация считывается читателем мгновенно и стала отдельным маркером.
|
|
238
238
|
|
|
239
|
-
**49. Рваная медитативность.** Стилизация под глубокомысленность через цепочку коротких отдельных предложений-кивков: «Короткие. Точные. Отдельные. Рефлексивные.» Это не то же самое, что #11 (однообразие длины): там вариативность, а здесь намеренная имитация «вдумчивости». Сигнал: 3+ односоставных предложения подряд, каждое из 1-3 слов, каждое как «откровение». Лечится восстановлением нормального предложения: «Хорошие тексты: короткие, точные, отдельные предложения работают лучше длинных периодов» вместо «Короткие. Точные. Отдельные.»
|
|
239
|
+
**49. Рваная медитативность.** Стилизация под глубокомысленность через цепочку коротких отдельных предложений-кивков: «Короткие. Точные. Отдельные. Рефлексивные.» Это не то же самое, что #11 (однообразие длины): там вариативность, а здесь намеренная имитация «вдумчивости». Сигнал: 3+ односоставных предложения подряд, каждое из 1-3 слов, каждое как «откровение». Лечится восстановлением нормального предложения: «Хорошие тексты: короткие, точные, отдельные предложения работают лучше длинных периодов» вместо «Короткие. Точные. Отдельные.» Сканер считает это правило сам: три и более утверждения по 1-3 слова подряд в одной строке дают штраф «рваная медитативность»; реплики диалога, восклицания, вопросы и пункты списков не считаются.
|
|
240
240
|
|
|
241
241
|
**50. Контрастные вопросы с короткими ответами.** Псевдо-сократический ритм: «Зачем? Потому что. И для чего? Для этого.» Имитация «диалога с читателем». Если в тексте 3+ риторических вопроса с 2-3-словными ответами, это AI-паттерн 2025-2026. Лечится: либо вопросы реальные (с развёрнутыми ответами), либо без вопросов вообще, утверждениями.
|
|
242
242
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Сверка чистовика свежим агентом
|
|
2
|
+
|
|
3
|
+
Ты проверяющий. Другой агент снял из текста следы нейросети, ты судишь результат.
|
|
4
|
+
Ты не делал правку, не видел рассуждений редактора и не ищешь их. Если рядом
|
|
5
|
+
лежит список изменений или пояснение, не читай: контекст намерений делает
|
|
6
|
+
проверяющего сговорчивым, а сговорчивый проверяющий бесполезен.
|
|
7
|
+
|
|
8
|
+
По умолчанию правка не проходит. Редактор уже считает её хорошей, иначе не
|
|
9
|
+
отдал бы. Твоя ценность в том, что ты ловишь то, чего он не видит в своём тексте.
|
|
10
|
+
|
|
11
|
+
## Что у тебя есть
|
|
12
|
+
|
|
13
|
+
1. Исходник.
|
|
14
|
+
2. Чистовик.
|
|
15
|
+
3. SKILL.md с таблицей жёстких запретов и правилами сверки.
|
|
16
|
+
|
|
17
|
+
## Сначала сканер, если доступен
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
python3 <папка-скилла>/scripts/scan.py <чистовик> --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Его отчёт это пол, а не вердикт: всё, что он нашёл, реально; всё, что пропустил,
|
|
24
|
+
не обязательно чисто. Сканер сопоставляет строки и не отличает выдуманное число
|
|
25
|
+
от исходного, не видит, уцелел ли довод, не замечает, что проза стала плоской.
|
|
26
|
+
Это твоя часть, и она решает исход.
|
|
27
|
+
|
|
28
|
+
## Два направления
|
|
29
|
+
|
|
30
|
+
Правка не проходит, если следы остались. И так же не проходит, если правка
|
|
31
|
+
сломала текст. Второе проверяют реже, поэтому выхолощенные тексты доходят до
|
|
32
|
+
читателя. Иди в этом порядке.
|
|
33
|
+
|
|
34
|
+
### Ущерб
|
|
35
|
+
|
|
36
|
+
- **Выдумка.** Каждую конкретику чистовика, которой нет в исходнике, найди в
|
|
37
|
+
исходнике: число, дату, имя, источник, цитату, версию, «мы проверяли», «по
|
|
38
|
+
опыту». Не нашёл, значит выдумано. Отдельно соединительная ткань: длительность,
|
|
39
|
+
причинная связка, приписанная реакция людей. Блокирует всегда, без оценки
|
|
40
|
+
тяжести.
|
|
41
|
+
- **Дрейф смысла.** Пройди утверждения исходника по порядку и найди каждое в
|
|
42
|
+
чистовике. Пропало, перевернулось, усилилось? «Может снизить задержку в части
|
|
43
|
+
сценариев» не становится «снижает задержку». Оговорки в медицине, праве,
|
|
44
|
+
финансах и прогнозах обязательны.
|
|
45
|
+
- **Выхолащивание.** Довод должен работать: причины, по которым утверждение
|
|
46
|
+
верно, на месте, переход от пункта к пункту читается. Сокращение сильнее чем
|
|
47
|
+
вдвое обычно значит, что вместе с водой ушло содержание.
|
|
48
|
+
- **Рамка.** Если названы клиент, партнёр или человек, сравни, как они выглядят
|
|
49
|
+
до и после. Снятая подводка бывала единственным смягчением.
|
|
50
|
+
- **Термины.** Термин, который был подлежащим, остаётся термином, а не «упрощён»
|
|
51
|
+
в туманное слово.
|
|
52
|
+
|
|
53
|
+
### Следы
|
|
54
|
+
|
|
55
|
+
- **Отчёт сканера** целиком, кроме строк с объяснённым исключением.
|
|
56
|
+
- **Шаблоны, которых сканер не видит.** Читай структуру, а не слова: отрицание с
|
|
57
|
+
разворотом, триады, риторические вопросы, стопки обрывков, «ярлык: вывод»,
|
|
58
|
+
абзацы с ударной концовкой. Один раз это голос, три раза это шаблон.
|
|
59
|
+
- **Переносимость.** Предложение встанет в текст другого автора без правки?
|
|
60
|
+
Вода, уцелевшая потому, что в ней нет слов-маркеров.
|
|
61
|
+
- **Выскобленный текст.** Все предложения одной короткой длины, все абзацы одной
|
|
62
|
+
формы, плоские утверждения стопкой. Ровность выдаёт машину в обе стороны.
|
|
63
|
+
- **Остатки чат-бота** и обращения к читателю, которого в опубликованном тексте
|
|
64
|
+
нет.
|
|
65
|
+
|
|
66
|
+
## Тяжесть и формат
|
|
67
|
+
|
|
68
|
+
Блокирует: выдумка, дрейф смысла, сломанный довод, жёсткий запрет. Правится:
|
|
69
|
+
пропущенный след или локальный ущерб. Заметка: вкус, скажи один раз и не настаивай.
|
|
70
|
+
|
|
71
|
+
Каждое замечание с цитатой и строкой. Замечание без цитаты это ощущение, его
|
|
72
|
+
отбрасывают. Не пиши «всё ещё звучит как нейросеть» без названного паттерна и не
|
|
73
|
+
выноси вердикт об авторстве: стиль говорит о качестве, а не о том, кто печатал.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
ВЕРДИКТ: ПРОШЛО | НЕ ПРОШЛО
|
|
77
|
+
СКАНЕР: N/100, K находок (или «не запускался»)
|
|
78
|
+
ОБЪЁМ: исходник -> чистовик (процент)
|
|
79
|
+
|
|
80
|
+
БЛОКИРУЕТ
|
|
81
|
+
- [строка N] что не так | «цитата» | что сделать
|
|
82
|
+
|
|
83
|
+
ПРАВИТСЯ
|
|
84
|
+
- [строка N] что не так | «цитата» | что сделать
|
|
85
|
+
|
|
86
|
+
ЗАМЕТКИ
|
|
87
|
+
- ...
|
|
88
|
+
|
|
89
|
+
НЕ СВЕРЕНО
|
|
90
|
+
- конкретика чистовика, которую не удалось найти в исходнике
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
«Прошло» требует пустого раздела «Блокирует». Чистовик действительно чист?
|
|
94
|
+
Скажи «прошло» и не выдумывай замечаний для видимости: они толкают редактора
|
|
95
|
+
к правкам, которые делают текст хуже.
|
|
@@ -30,6 +30,8 @@ class RhythmStats:
|
|
|
30
30
|
ellipsis: int # многоточий
|
|
31
31
|
parentheses: int # скобочных ремарок
|
|
32
32
|
questions: int # вопросительных предложений
|
|
33
|
+
staccato_runs: int = 0 # цепочек из 3+ обрывков подряд (каталог #49)
|
|
34
|
+
staccato_max: int = 0 # длина самой длинной такой цепочки
|
|
33
35
|
|
|
34
36
|
def as_dict(self) -> dict:
|
|
35
37
|
return self.__dict__.copy()
|
|
@@ -42,6 +44,41 @@ def _word_count(sentence: str) -> int:
|
|
|
42
44
|
return sum(1 for t in tokenize(sentence) if _WORD_RE.search(t.text))
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
# Рваная медитативность (каталог #49): «Короткие. Точные. Отдельные.» Цепочка
|
|
48
|
+
# из трёх и более утверждений по 1-3 слова подряд. Не считаются реплики диалога
|
|
49
|
+
# (строка с тире или кавычки в начале), восклицания, вопросы, подводки с
|
|
50
|
+
# двоеточием, пункты списков и заголовки: там обрывки законны. Порог выбран по
|
|
51
|
+
# 400 постам Пикабу: доля обрывков в тексте срабатывала на 5-12% живых текстов
|
|
52
|
+
# и отвергнута, цепочка из трёх подряд даёт 4 из 400 (eval/OVERCORRECTION_CHECK.md).
|
|
53
|
+
STACCATO_MAX_WORDS = 3
|
|
54
|
+
STACCATO_MIN_RUN = 3
|
|
55
|
+
_DIALOG_RE = re.compile(r"^\s*[-–—«\"']")
|
|
56
|
+
_SKIP_LINE_RE = re.compile(r"^\s*(?:(?:[-*•]|\d+[.)])\s+|#)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def staccato_runs(text: str) -> tuple[int, int]:
|
|
60
|
+
"""(число цепочек, длина самой длинной). Цепочка не переходит через строку."""
|
|
61
|
+
runs: list[int] = []
|
|
62
|
+
for line in text.replace("\\", "").split("\n"):
|
|
63
|
+
if not line.strip() or _SKIP_LINE_RE.match(line):
|
|
64
|
+
continue
|
|
65
|
+
cur = 0
|
|
66
|
+
for s in sentenize(line):
|
|
67
|
+
st = s.text.strip()
|
|
68
|
+
n = _word_count(st)
|
|
69
|
+
if not n:
|
|
70
|
+
continue
|
|
71
|
+
if n <= STACCATO_MAX_WORDS and not _DIALOG_RE.match(st) and st[-1] not in "?!:;":
|
|
72
|
+
cur += 1
|
|
73
|
+
else:
|
|
74
|
+
if cur >= STACCATO_MIN_RUN:
|
|
75
|
+
runs.append(cur)
|
|
76
|
+
cur = 0
|
|
77
|
+
if cur >= STACCATO_MIN_RUN:
|
|
78
|
+
runs.append(cur)
|
|
79
|
+
return len(runs), max(runs, default=0)
|
|
80
|
+
|
|
81
|
+
|
|
45
82
|
def rhythm(text: str) -> RhythmStats:
|
|
46
83
|
sents = [s.text for s in sentenize(text)]
|
|
47
84
|
lengths = [_word_count(s) for s in sents]
|
|
@@ -49,6 +86,7 @@ def rhythm(text: str) -> RhythmStats:
|
|
|
49
86
|
n = len(lengths)
|
|
50
87
|
total_words = sum(lengths)
|
|
51
88
|
|
|
89
|
+
runs, longest = staccato_runs(text)
|
|
52
90
|
mean = statistics.mean(lengths) if lengths else 0.0
|
|
53
91
|
stdev = statistics.pstdev(lengths) if n > 1 else 0.0
|
|
54
92
|
cv = (stdev / mean) if mean else 0.0
|
|
@@ -67,6 +105,8 @@ def rhythm(text: str) -> RhythmStats:
|
|
|
67
105
|
ellipsis=text.count("…") + len(re.findall(r"\.\.\.", text)),
|
|
68
106
|
parentheses=text.count("("),
|
|
69
107
|
questions=sum(1 for s in sents if s.rstrip().endswith("?")),
|
|
108
|
+
staccato_runs=runs,
|
|
109
|
+
staccato_max=longest,
|
|
70
110
|
)
|
|
71
111
|
|
|
72
112
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""MCP-сервер humanizer-ru: сканер и факт-замок по stdio.
|
|
2
|
+
|
|
3
|
+
Без зависимостей сверх самого пакета: JSON-RPC 2.0 построчно через stdin и
|
|
4
|
+
stdout, как требует транспорт stdio в MCP. Два инструмента:
|
|
5
|
+
|
|
6
|
+
scan_text балл чистоты, штрафы, запреты и маркеры с позициями
|
|
7
|
+
compare_texts «было и стало» плюс факт-замок между исходником и правкой
|
|
8
|
+
|
|
9
|
+
Запуск: ru-humanizer-mcp (точка входа пакета ru-humanizer) или
|
|
10
|
+
python -m humanizer_metrics.mcp_server. Логи только в stderr: stdout занят
|
|
11
|
+
протоколом.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from importlib import metadata
|
|
19
|
+
|
|
20
|
+
from . import analyze, cleanliness_score, diff_facts, facts_verdict
|
|
21
|
+
from .markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES, GENRES,
|
|
22
|
+
effective_hard_bans, mute_by_genre)
|
|
23
|
+
|
|
24
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
25
|
+
SERVER_NAME = "humanizer-ru"
|
|
26
|
+
|
|
27
|
+
GENRE_SCHEMA = {
|
|
28
|
+
"type": ["string", "null"],
|
|
29
|
+
"enum": [*GENRES, None],
|
|
30
|
+
"description": "Жанр текста. По умолчанию marketing (строгий режим). "
|
|
31
|
+
"academic, legal, fiction и news снимают маркеры, законные для регистра.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
TOOLS = [
|
|
35
|
+
{
|
|
36
|
+
"name": "scan_text",
|
|
37
|
+
"title": "Проверить русский текст на следы нейросети",
|
|
38
|
+
"description": (
|
|
39
|
+
"Балл чистоты 0-100 (85 и выше чисто, 60-84 точечная правка, ниже 60 рерайт), "
|
|
40
|
+
"штрафы с причинами, жёсткие запреты и мягкие маркеры с позициями в тексте, "
|
|
41
|
+
"ритм предложений, морфология и структура. Не детектор авторства: показывает, "
|
|
42
|
+
"что править."
|
|
43
|
+
),
|
|
44
|
+
"inputSchema": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"text": {"type": "string", "description": "Русский текст для проверки"},
|
|
48
|
+
"genre": GENRE_SCHEMA,
|
|
49
|
+
},
|
|
50
|
+
"required": ["text"],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "compare_texts",
|
|
55
|
+
"title": "Сравнить исходник и правку",
|
|
56
|
+
"description": (
|
|
57
|
+
"Балл «было и стало» с дельтой и факт-замок: числа, даты, имена, ссылки и код "
|
|
58
|
+
"исходника должны дожить до правки, а новых появиться не должно. Отдельно "
|
|
59
|
+
"кванторы («впервые», «единственный», «в России»), возникшие без источника."
|
|
60
|
+
),
|
|
61
|
+
"inputSchema": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"properties": {
|
|
64
|
+
"before": {"type": "string", "description": "Текст до правки"},
|
|
65
|
+
"after": {"type": "string", "description": "Текст после правки"},
|
|
66
|
+
"genre": GENRE_SCHEMA,
|
|
67
|
+
},
|
|
68
|
+
"required": ["before", "after"],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _version() -> str:
|
|
75
|
+
try:
|
|
76
|
+
return metadata.version("ru-humanizer")
|
|
77
|
+
except metadata.PackageNotFoundError:
|
|
78
|
+
return "dev"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _hits(hits) -> list[dict]:
|
|
82
|
+
return [{"category": h.category, "name": h.marker, "count": h.count,
|
|
83
|
+
"positions": list(h.positions)} for h in hits]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def scan(text: str, genre: str | None = None) -> dict:
|
|
87
|
+
"""Тот же расчёт, что в scan.py: балл по жанру, частотные баны и жанровый фильтр."""
|
|
88
|
+
genre = genre or "marketing"
|
|
89
|
+
if genre not in GENRES:
|
|
90
|
+
raise ValueError(f"неизвестный жанр {genre!r}, допустимы: {', '.join(GENRES)}")
|
|
91
|
+
rep = analyze(text)
|
|
92
|
+
sc = cleanliness_score(rep, genre)
|
|
93
|
+
bans = mute_by_genre(effective_hard_bans(rep.hard_bans, rep.rhythm.words),
|
|
94
|
+
genre, GENRE_MUTED_BANS)
|
|
95
|
+
soft = mute_by_genre(rep.markers, genre, GENRE_MUTED_CATEGORIES)
|
|
96
|
+
return {
|
|
97
|
+
"score": sc.score,
|
|
98
|
+
"band": sc.band,
|
|
99
|
+
"penalties": [{"reason": r, "points": p} for r, p in sc.penalties],
|
|
100
|
+
"notes": list(sc.notes),
|
|
101
|
+
"genre": genre,
|
|
102
|
+
"words": rep.rhythm.words,
|
|
103
|
+
"hard_bans": _hits(bans),
|
|
104
|
+
"hard_ban_count": sum(h.count for h in bans),
|
|
105
|
+
"markers": _hits(soft),
|
|
106
|
+
"marker_count": sum(h.count for h in soft),
|
|
107
|
+
"rhythm": rep.rhythm.as_dict(),
|
|
108
|
+
"morph": rep.morph.as_dict(),
|
|
109
|
+
"structure": rep.structure.as_dict(),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _report(d: dict) -> str:
|
|
114
|
+
lines = [f"ЧИСТОТА: {d['score']}/100 [{d['band']}] ({d['words']} слов, жанр {d['genre']})",
|
|
115
|
+
" (≥85 чисто · 60-84 точечная правка · <60 рерайт)"]
|
|
116
|
+
for p in d["penalties"]:
|
|
117
|
+
lines.append(f" {p['points']:+d} {p['reason']}")
|
|
118
|
+
if not d["penalties"]:
|
|
119
|
+
lines.append(" без штрафов")
|
|
120
|
+
for n in d["notes"]:
|
|
121
|
+
lines.append(f" ℹ {n}")
|
|
122
|
+
lines.append("")
|
|
123
|
+
lines.append("HARD BANS:")
|
|
124
|
+
if d["hard_bans"]:
|
|
125
|
+
for h in d["hard_bans"]:
|
|
126
|
+
lines.append(f" ⛔ {h['name']} ×{h['count']}")
|
|
127
|
+
else:
|
|
128
|
+
lines.append(" ✓ чисто")
|
|
129
|
+
lines.append("")
|
|
130
|
+
lines.append(f"Маркеры: {d['marker_count']}")
|
|
131
|
+
for h in sorted(d["markers"], key=lambda x: -x["count"])[:12]:
|
|
132
|
+
lines.append(f" • [{h['category']}] «{h['name']}» ×{h['count']}")
|
|
133
|
+
return "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def compare(before: str, after: str, genre: str | None = None) -> dict:
|
|
137
|
+
b, a = scan(before, genre), scan(after, genre)
|
|
138
|
+
fd = diff_facts(before, after)
|
|
139
|
+
return {
|
|
140
|
+
"before": {"score": b["score"], "band": b["band"], "hard_ban_count": b["hard_ban_count"],
|
|
141
|
+
"marker_count": b["marker_count"]},
|
|
142
|
+
"after": a,
|
|
143
|
+
"delta": a["score"] - b["score"],
|
|
144
|
+
"facts": fd.as_dict(),
|
|
145
|
+
"facts_verdict": facts_verdict(fd),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _compare_report(d: dict) -> str:
|
|
150
|
+
b, a = d["before"], d["after"]
|
|
151
|
+
delta = d["delta"]
|
|
152
|
+
lines = [f"ЧИСТОТА: было {b['score']}, стало {a['score']}/100 [{a['band']}] ({delta:+d})",
|
|
153
|
+
f" запретов: {b['hard_ban_count']} → {a['hard_ban_count']}, "
|
|
154
|
+
f"маркеров: {b['marker_count']} → {a['marker_count']}",
|
|
155
|
+
"", "Факт-замок:", f" {d['facts_verdict']}"]
|
|
156
|
+
f = d["facts"]
|
|
157
|
+
lines += [f" ✗ новое: {x}" for x in f["added"]]
|
|
158
|
+
lines += [f" ⚠ потеряно: {x}" for x in f["lost"]]
|
|
159
|
+
lines += [f" ⚠ утверждение появилось: {x}" for x in f["claims_added"]]
|
|
160
|
+
if f["soft_added"]:
|
|
161
|
+
lines.append(f" ℹ мелкие количества появились: {', '.join(f['soft_added'])}")
|
|
162
|
+
return "\n".join(lines)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def call_tool(name: str, args: dict) -> dict:
|
|
166
|
+
if name == "scan_text":
|
|
167
|
+
text = args.get("text")
|
|
168
|
+
if not isinstance(text, str):
|
|
169
|
+
raise ValueError("нужен строковый аргумент text")
|
|
170
|
+
if not text.strip():
|
|
171
|
+
return {"content": [{"type": "text", "text": "Текст пуст, сканировать нечего."}],
|
|
172
|
+
"structuredContent": {"empty": True}, "isError": False}
|
|
173
|
+
d = scan(text, args.get("genre"))
|
|
174
|
+
return {"content": [{"type": "text", "text": _report(d)}],
|
|
175
|
+
"structuredContent": d, "isError": False}
|
|
176
|
+
if name == "compare_texts":
|
|
177
|
+
before, after = args.get("before"), args.get("after")
|
|
178
|
+
if not isinstance(before, str) or not isinstance(after, str):
|
|
179
|
+
raise ValueError("нужны строковые аргументы before и after")
|
|
180
|
+
d = compare(before, after, args.get("genre"))
|
|
181
|
+
return {"content": [{"type": "text", "text": _compare_report(d)}],
|
|
182
|
+
"structuredContent": d, "isError": False}
|
|
183
|
+
raise KeyError(name)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def handle(req: dict) -> dict | None:
|
|
187
|
+
"""Ответ на один запрос; None для уведомлений (без id)."""
|
|
188
|
+
method = req.get("method", "")
|
|
189
|
+
rid = req.get("id")
|
|
190
|
+
params = req.get("params") or {}
|
|
191
|
+
is_notification = "id" not in req
|
|
192
|
+
|
|
193
|
+
def ok(result):
|
|
194
|
+
return None if is_notification else {"jsonrpc": "2.0", "id": rid, "result": result}
|
|
195
|
+
|
|
196
|
+
def err(code, message):
|
|
197
|
+
return None if is_notification else {"jsonrpc": "2.0", "id": rid,
|
|
198
|
+
"error": {"code": code, "message": message}}
|
|
199
|
+
|
|
200
|
+
if method == "initialize":
|
|
201
|
+
return ok({
|
|
202
|
+
"protocolVersion": params.get("protocolVersion") or PROTOCOL_VERSION,
|
|
203
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
204
|
+
"serverInfo": {"name": SERVER_NAME, "version": _version()},
|
|
205
|
+
"instructions": ("Сканер следов нейросети для русского текста. scan_text даёт балл "
|
|
206
|
+
"и список того, что править; compare_texts сверяет правку с "
|
|
207
|
+
"исходником по баллу и фактам. Балл не вердикт об авторстве."),
|
|
208
|
+
})
|
|
209
|
+
if method == "ping":
|
|
210
|
+
return ok({})
|
|
211
|
+
if method == "tools/list":
|
|
212
|
+
return ok({"tools": TOOLS})
|
|
213
|
+
if method == "tools/call":
|
|
214
|
+
name = params.get("name", "")
|
|
215
|
+
try:
|
|
216
|
+
return ok(call_tool(name, params.get("arguments") or {}))
|
|
217
|
+
except KeyError:
|
|
218
|
+
return err(-32602, f"неизвестный инструмент: {name}")
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
return ok({"content": [{"type": "text", "text": f"Ошибка: {exc}"}], "isError": True})
|
|
221
|
+
if method.startswith("notifications/"):
|
|
222
|
+
return None
|
|
223
|
+
return err(-32601, f"метод не поддерживается: {method}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> int:
|
|
227
|
+
stdin = sys.stdin.buffer
|
|
228
|
+
out = sys.stdout.buffer
|
|
229
|
+
for raw in stdin:
|
|
230
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
231
|
+
if not line:
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
req = json.loads(line)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
resp = {"jsonrpc": "2.0", "id": None,
|
|
237
|
+
"error": {"code": -32700, "message": "невалидный JSON"}}
|
|
238
|
+
else:
|
|
239
|
+
try:
|
|
240
|
+
resp = handle(req) if isinstance(req, dict) else {
|
|
241
|
+
"jsonrpc": "2.0", "id": None,
|
|
242
|
+
"error": {"code": -32600, "message": "ожидался объект запроса"}}
|
|
243
|
+
except Exception as exc: # noqa: BLE001 (сервер не должен падать на одном запросе)
|
|
244
|
+
print(f"[humanizer-ru mcp] {exc!r}", file=sys.stderr)
|
|
245
|
+
resp = {"jsonrpc": "2.0", "id": req.get("id"),
|
|
246
|
+
"error": {"code": -32603, "message": str(exc)}}
|
|
247
|
+
if resp is not None:
|
|
248
|
+
out.write((json.dumps(resp, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
249
|
+
out.flush()
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
raise SystemExit(main())
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
|
|
16
|
-
from .burstiness import CV_HUMAN_TARGET
|
|
16
|
+
from .burstiness import CV_HUMAN_TARGET, STACCATO_MIN_RUN
|
|
17
17
|
from .markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES,
|
|
18
18
|
effective_hard_bans, mute_by_genre)
|
|
19
19
|
from .morphology import NV_TARGET
|
|
@@ -50,6 +50,14 @@ BAND_EDIT = 60 # ≥ — точечная правка; < — полный р
|
|
|
50
50
|
HUMAN_ZERO_SHARE: tuple[tuple[int, float], ...] = ((100, 42.2), (200, 21.0), (400, 15.1))
|
|
51
51
|
STERILE_MIN_WORDS = 100
|
|
52
52
|
|
|
53
|
+
# Рваная медитативность (каталог #49): цепочка обрывков «Короткие. Точные.
|
|
54
|
+
# Отдельные.» это почерк хуманайзера, который вывернул ровный ритм наизнанку:
|
|
55
|
+
# обратная сторона штрафа за ровный ритм. Штраф мягкий, как
|
|
56
|
+
# у номинальности: на 400 постах Пикабу правило срабатывает на 4, и половина из
|
|
57
|
+
# них настоящие авторские обрывки.
|
|
58
|
+
STACCATO_PENALTY = 8
|
|
59
|
+
STACCATO_PENALTY_MAX = 14
|
|
60
|
+
|
|
53
61
|
|
|
54
62
|
@dataclass
|
|
55
63
|
class ScoreResult:
|
|
@@ -75,6 +83,14 @@ def _band(score: float) -> str:
|
|
|
75
83
|
return "рерайт"
|
|
76
84
|
|
|
77
85
|
|
|
86
|
+
def _plural(n: int, one: str, few: str, many: str) -> str:
|
|
87
|
+
if n % 10 == 1 and n % 100 != 11:
|
|
88
|
+
return one
|
|
89
|
+
if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14:
|
|
90
|
+
return few
|
|
91
|
+
return many
|
|
92
|
+
|
|
93
|
+
|
|
78
94
|
def _per100(count: int, words: int) -> float:
|
|
79
95
|
return (count / words * 100) if words else 0.0
|
|
80
96
|
|
|
@@ -137,6 +153,16 @@ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
|
|
|
137
153
|
score -= pen
|
|
138
154
|
penalties.append((f"ровный ритм (CV={cv}, цель ≥{CV_HUMAN_TARGET})", -pen))
|
|
139
155
|
|
|
156
|
+
# 5б. Рваная медитативность: цепочки обрывков подряд. Обратная сторона
|
|
157
|
+
# ровного ритма, поэтому стоит рядом с ним.
|
|
158
|
+
runs = report.rhythm.staccato_runs
|
|
159
|
+
if runs:
|
|
160
|
+
pen = min(STACCATO_PENALTY_MAX, STACCATO_PENALTY * runs)
|
|
161
|
+
score -= pen
|
|
162
|
+
penalties.append((
|
|
163
|
+
f"рваная медитативность: {runs} {_plural(runs, 'цепочка', 'цепочки', 'цепочек')} "
|
|
164
|
+
f"обрывков по {STACCATO_MIN_RUN}+ подряд (самая длинная {report.rhythm.staccato_max})", -pen))
|
|
165
|
+
|
|
140
166
|
# 6. Номинальность: сущ./глаг. выше цели 2.5 = канцелярит. Слабый сигнал и
|
|
141
167
|
# главный источник ложных срабатываний (энциклопедический/юр. регистр
|
|
142
168
|
# легитимно номинален), поэтому штраф мягкий и низко ограничен.
|
|
@@ -66,6 +66,11 @@ def _cyrillic_share(text: str) -> float:
|
|
|
66
66
|
|
|
67
67
|
|
|
68
68
|
def main() -> int:
|
|
69
|
+
# `ru-humanizer mcp` поднимает MCP-сервер той же командой, что и сканер:
|
|
70
|
+
# так карточка в реестре MCP запускает пакет как `uvx ru-humanizer mcp`.
|
|
71
|
+
if sys.argv[1:2] == ["mcp"]:
|
|
72
|
+
from humanizer_metrics.mcp_server import main as mcp_main
|
|
73
|
+
return mcp_main()
|
|
69
74
|
ap = argparse.ArgumentParser(description="Детерминированный сканер AI-маркеров (humanizer-ru)")
|
|
70
75
|
ap.add_argument("source", help="файл с текстом или '-' для stdin")
|
|
71
76
|
ap.add_argument("--json", action="store_true", help="вывод в JSON")
|