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 CHANGED
@@ -5,7 +5,7 @@
5
5
  Claude Code / Cowork plugin. Kills AI smell in Russian text. The English [humanizer](https://github.com/blader/humanizer) won't help here. Russian AI markers are their own beast: bureaucratic noun-chains (канцелярит), English-syntax calques, missing particles like "же" and "ведь" that make Russian sound alive.
6
6
 
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![Version](https://img.shields.io/badge/version-3.25.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Version](https://img.shields.io/badge/version-3.27.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
9
9
  [![Stars](https://img.shields.io/github/stars/ilyautov/humanizer-ru?style=social)](https://github.com/ilyautov/humanizer-ru/stargazers)
10
10
  [![skills.sh](https://skills.sh/b/ilyautov/humanizer-ru)](https://skills.sh/ilyautov/humanizer-ru/humanizer-ru)
11
11
  [![npm](https://img.shields.io/npm/v/humanizer-ru?label=npm%20%C2%B7%20dsh)](https://www.npmjs.com/package/humanizer-ru)
@@ -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: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![Версия](https://img.shields.io/badge/версия-3.25.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Версия](https://img.shields.io/badge/версия-3.27.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
9
9
  [![Звёзды](https://img.shields.io/github/stars/ilyautov/humanizer-ru?style=social)](https://github.com/ilyautov/humanizer-ru/stargazers)
10
10
  [![skills.sh](https://skills.sh/b/ilyautov/humanizer-ru)](https://skills.sh/ilyautov/humanizer-ru/humanizer-ru)
11
11
  [![npm](https://img.shields.io/npm/v/humanizer-ru?label=npm%20%C2%B7%20dsh)](https://www.npmjs.com/package/humanizer-ru)
@@ -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
- Все проекты: [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.25.0",
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.25.0
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")