humanizer-ru 3.24.0 → 3.26.2
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 +46 -1
- package/README.md +34 -19
- package/package.json +1 -1
- package/skills/humanizer-ru/SKILL.md +10 -2
- package/skills/humanizer-ru/references/catalog.md +1 -1
- 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,11 +172,40 @@ 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.
|
|
178
206
|
- **Audit**: diagnosis only, returns detected patterns with priority A-D.
|
|
179
207
|
- **Targeted fix**: works on a specific category only.
|
|
208
|
+
- **Own draft** (silent, unprompted): while the skill is installed, the agent runs the Russian prose it writes itself past the 21 hard bans before you ever see it. No report, no ask. Text that is not the agent's own (your message, a file, a quote, someone's code) is never edited silently: it goes through the three modes above, where the edit is visible. Saying «не правь» turns the mode off for the rest of the conversation.
|
|
180
209
|
|
|
181
210
|
## Usage
|
|
182
211
|
|
|
@@ -232,3 +261,19 @@ Full list, including bug reports that changed the code: [CONTRIBUTORS.md](CONTRI
|
|
|
232
261
|
## License
|
|
233
262
|
|
|
234
263
|
MIT
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
## Who built this
|
|
268
|
+
|
|
269
|
+
[Ilya Utov](https://github.com/ilyautov), the [AI Frontier](https://aifrontier.tech) lab. I write about how these tools work inside on [Telegram](https://t.me/gorilla_under_hood) and [LinkedIn](https://www.linkedin.com/in/ilyautov).
|
|
270
|
+
|
|
271
|
+
**Nearby:**
|
|
272
|
+
|
|
273
|
+
- [**marketplaces-mcp-ru**](https://github.com/ilyautov/marketplaces-mcp-ru): Wildberries, Ozon, Yandex Market and Avito straight from the agent
|
|
274
|
+
- [**small-business-ru**](https://github.com/ilyautov/small-business-ru): 34 skills for Russian small business, the numbers computed in code
|
|
275
|
+
- [**consilium-principis**](https://github.com/ilyautov/consilium-principis): a board of thinkers where every quote is checked word for word
|
|
276
|
+
- [**hefest**](https://github.com/ilyautov/hefest): chemical safety for an industrial plant, kept inside the plant's own network
|
|
277
|
+
- [**cordon**](https://github.com/ilyautov/cordon): a deterministic layer between untrusted content and agent actions
|
|
278
|
+
|
|
279
|
+
Everything else: [github.com/ilyautov](https://github.com/ilyautov). Useful? Star it, that is how other people find it.
|
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-маркеров своя физика: канцелярит («осуществление внедрения»), кальки с английского синтаксиса («стоит отметить, что»), отсутствующие частицы («же», «ведь», «вот»), которыми живой русский дышит. С английскими паттернами это не пересекается вообще.
|
|
@@ -72,10 +74,11 @@ Claude.ai, DeepSeek Harness и раскатка на команду в [разд
|
|
|
72
74
|
|
|
73
75
|
**Калибровка под голос автора.** Если дать образцы своего письма, скилл выписывает ритм, лексику, пунктуацию и тон и держит замены в этих рамках. Это ограничение, а не разрешение дописывать в вашей манере.
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
**Четыре режима работы:**
|
|
76
78
|
- Редактура (по умолчанию): диагностика сканером, разметка, правка, сверка, отчёт «было N, стало M»
|
|
77
79
|
- Аудит: только диагностика, текст не трогает
|
|
78
80
|
- Точечная правка: работа с одной категорией
|
|
81
|
+
- Свой черновик (молча, без просьбы): пока скилл установлен, агент прогоняет по таблице жёстких банов то, что пишет по-русски сам, до того как вы это увидите. Чужой текст молча не правится никогда: ваше сообщение, файл, цитата и чужой код идут только через режимы выше, и правку вы видите. «Не правь» выключает режим до конца разговора
|
|
79
82
|
|
|
80
83
|
**Что ловят детекторы:** секция с числами из исследований 2025-2026. DivEye: вторые производные surprisal дают 39.4% вклада в детекцию. Perplexity gap 29.5 vs 15.2 (человек vs LLM). AINL-Eval 2025: лучший результат на тесте около 86% (52K русских текстов, 12 доменов). Все ссылки и цифры верифицированы по первоисточникам, см. [SOURCES.md](SOURCES.md).
|
|
81
84
|
|
|
@@ -213,6 +216,34 @@ cp -r humanizer-ru/skills/humanizer-ru ~/.agents/skills/
|
|
|
213
216
|
|
|
214
217
|
dsh сканирует `~/.agents/skills` и `~/.dsh/skills` сам, перезапуск не нужен. Ключ `allowed-tools` из фронтматтера dsh не читает, а `references/` разрешает относительно папки скилла, так что каталог и журнал правок открываются как в Claude Code.
|
|
215
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
|
+
|
|
216
247
|
## Использование
|
|
217
248
|
|
|
218
249
|
Попросите Claude по-русски:
|
|
@@ -380,20 +411,4 @@ MIT: используйте свободно, форкайте, дорабаты
|
|
|
380
411
|
- [**hefest**](https://github.com/ilyautov/hefest): химическая безопасность завода, целиком офлайн
|
|
381
412
|
- [**cordon**](https://github.com/ilyautov/cordon): детерминированный слой между недоверенным контентом и действиями агента
|
|
382
413
|
|
|
383
|
-
Все
|
|
384
|
-
|
|
385
|
-
---
|
|
386
|
-
|
|
387
|
-
## Кто это сделал
|
|
388
|
-
|
|
389
|
-
[Илья Утов](https://github.com/ilyautov), лаборатория [AI Frontier](https://aifrontier.tech). Как эти инструменты устроены внутри, пишу в [Telegram](https://t.me/gorilla_under_hood) и [LinkedIn](https://www.linkedin.com/in/ilyautov).
|
|
390
|
-
|
|
391
|
-
**Рядом стоят:**
|
|
392
|
-
|
|
393
|
-
- [**marketplaces-mcp-ru**](https://github.com/ilyautov/marketplaces-mcp-ru): Wildberries, Ozon, Яндекс Маркет и Авито прямо из агента
|
|
394
|
-
- [**small-business-ru**](https://github.com/ilyautov/small-business-ru): 34 скилла для малого бизнеса, считают налоги и проверяют контрагента по ИНН
|
|
395
|
-
- [**consilium-principis**](https://github.com/ilyautov/consilium-principis): совет мыслителей, где каждая цитата сверяется дословно
|
|
396
|
-
- [**hefest**](https://github.com/ilyautov/hefest): химическая безопасность завода, целиком офлайн
|
|
397
|
-
- [**cordon**](https://github.com/ilyautov/cordon): детерминированный слой между недоверенным контентом и действиями агента
|
|
398
|
-
|
|
399
|
-
Все проекты: [github.com/ilyautov](https://github.com/ilyautov). Пригодилось, поставьте звезду: по ней это находят другие.
|
|
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.26.2",
|
|
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",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: humanizer-ru
|
|
3
|
-
description: "
|
|
3
|
+
description: "Качество русского текста для агента. Russian text quality for agents: proofreading and AI-slop cleanup for Cyrillic text. Триггеры: очеловечь, вычитай, отредактируй, поправь, причеши, проверь текст, убери канцелярит, humanizer. Молча снимает жёсткие запреты в собственном русском выводе агента; чужой текст молча не трогает. Use when надо убрать из русского текста канцелярит, воду и нейросетевую манеру, не сломав смысл, факты и голос автора. Три режима по запросу: редактура, аудит без переписывания, точечная правка одной категории. Со скиллом едет детерминированный сканер scripts/scan.py: считает хард-баны, категории маркеров, ритм и морфологию, печатает ЧИСТОТА: N/100, поэтому правка измерима («было N, стало M»). Флаг --genre academic/legal/fiction/news снимает маркеры, законные для научного, юридического и новостного регистра. Работает ТОЛЬКО с русским: для английского humanizer, для итальянского humanizer-it. НЕ используй для: перевод, написание с нуля, грамматика без стилистики, код."
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# Humanizer-RU v3.
|
|
7
|
+
# Humanizer-RU v3.26.2
|
|
8
8
|
|
|
9
9
|
Ты редактор. Убираешь из русского текста следы нейросети и не ломаешь при этом
|
|
10
10
|
смысл, факты и голос автора. Оба обещания равноправны: текст, который стал «чище»
|
|
@@ -26,6 +26,14 @@ license: MIT
|
|
|
26
26
|
прочитай [references/audit.md](references/audit.md) и верни разбор.
|
|
27
27
|
- **Точечная правка** («убери только канцелярит», «исправь тире»): работай только
|
|
28
28
|
с названной проблемой, остальное не трогай.
|
|
29
|
+
- **Свой черновик** (без просьбы, молча): всё, что ты пишешь пользователю
|
|
30
|
+
по-русски сам, перед отправкой проходит по таблице жёстких запретов. Это одна
|
|
31
|
+
вычитка своего текста, а не сеанс редактуры: сканер не запускается, каталог не
|
|
32
|
+
открывается, отчёта нет, скилл не упоминается. Чужой текст молча не правится
|
|
33
|
+
никогда. Чужое это сообщение пользователя, содержимое файла, цитата, выдача
|
|
34
|
+
поиска, чужой код и вообще всё, что ты не сочинил сам: с ним работают режимы
|
|
35
|
+
выше, и правку пользователь видит. Сказал «не правь» или задал свой стиль:
|
|
36
|
+
режим выключен до конца разговора.
|
|
29
37
|
- **Голос по образцам**: если пользователь дал свои тексты, выпиши в 3-5 строк
|
|
30
38
|
ритм, лексику, пунктуацию и тон. Это ограничение на замены, а не разрешение
|
|
31
39
|
дописывать в его манере.
|
|
@@ -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
|
|
|
@@ -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")
|