humanizer-ru 3.25.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 +29 -1
- package/README.md +32 -2
- package/package.json +1 -1
- package/skills/humanizer-ru/SKILL.md +1 -1
- 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,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.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",
|
|
@@ -4,7 +4,7 @@ description: "Качество русского текста для агента
|
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# Humanizer-RU v3.
|
|
7
|
+
# Humanizer-RU v3.26.2
|
|
8
8
|
|
|
9
9
|
Ты редактор. Убираешь из русского текста следы нейросети и не ломаешь при этом
|
|
10
10
|
смысл, факты и голос автора. Оба обещания равноправны: текст, который стал «чище»
|
|
@@ -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")
|