humanizer-ru 3.19.2 → 3.21.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 +16 -1
- package/README.md +24 -3
- package/package.json +1 -1
- package/skills/humanizer-ru/SKILL.md +2 -2
- package/skills/humanizer-ru/references/catalog.md +2 -2
- package/skills/humanizer-ru/scripts/humanizer_metrics/__init__.py +11 -51
- package/skills/humanizer-ru/scripts/humanizer_metrics/burstiness.py +1 -1
- package/skills/humanizer-ru/scripts/humanizer_metrics/facts.py +168 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/markdown.py +127 -0
- package/skills/humanizer-ru/scripts/scan.py +32 -3
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)
|
|
@@ -16,12 +16,27 @@ Claude Code / Cowork plugin. Kills AI smell in Russian text. The English [humani
|
|
|
16
16
|
</a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
+
🧪 **Live demo:** [humanizer-ru.aifrontier.tech](https://humanizer-ru.aifrontier.tech/#audit). Paste a Russian text, get a 0-100 cleanliness score and highlighted markers. Same scanner as in the skill, runs in the browser.
|
|
20
|
+
|
|
21
|
+
**Quick start** in Claude Code, two commands:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
/plugin marketplace add ilyautov/humanizer-ru
|
|
25
|
+
/plugin install humanizer-ru@ilyautov-plugins
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Cursor, Copilot, Cline and other agents: `npx skills add https://github.com/ilyautov/humanizer-ru/tree/main/skills/humanizer-ru`. Claude.ai, Codex, DeepSeek Harness and team rollout are covered in [Install](#install).
|
|
29
|
+
|
|
19
30
|
📖 **Docs & write-ups (RU):** [humanizer-ru.aifrontier.tech](https://humanizer-ru.aifrontier.tech/): do AI detectors work on Russian, the 64 markers, plagiarism vs AI detection.
|
|
20
31
|
|
|
21
32
|
## What you get
|
|
22
33
|
|
|
23
34
|
64 patterns across 14 categories: канцелярит, English calques, emotional sterility, persuasion tricks, information rhythm, hedging specifics, plus the 2025-2026 stylistic fingerprints (jagged-meditation single-word sentences, pseudo-Socratic Q-A chains, decorative emoji per list item, pseudo-therapeutic register) and the 2026 formulas (inanimate subject, Title Case headings, mid-sentence truncation, negation triad), plus a discourse layer (explicit final moral, portrait-style character introduction, emotion conveyed only through the body, seamless causal chains, strictly linear chronology, no direct reader address: narrative signals that survive stylistic rewriting, per StoryScope / COLM 2026). 21 hard-banned constructions that scream "GPT wrote this", including em-dashes (detectors count their frequency). A research-backed section on how detectors actually work (perplexity, burstiness, morphology) with verified numbers from DivEye, PIFE, AINL-Eval 2025 (every citation checked, see SOURCES.md). Five article formulas. Voice calibration. Quad-pass audit with a "Skeleton" pass that reads only the first lines of list items to catch templated openings.
|
|
24
35
|
|
|
36
|
+
## Try it without installing
|
|
37
|
+
|
|
38
|
+
The [site's front page](https://humanizer-ru.aifrontier.tech/#audit) runs the same scanner in JavaScript: paste a text, get a 0-100 cleanliness score, highlighted markers and the install command for your agent. Everything runs in the browser, nothing is uploaded. Rules are exported from `markers.py` by `scripts/export_web_rules.py`; `scripts/test_web_parity.py` keeps the web and Python engines in step in CI.
|
|
39
|
+
|
|
25
40
|
## Install
|
|
26
41
|
|
|
27
42
|
Three deployment channels: upload to Claude.ai web UI, roll out across an organization, or install into local agents (Claude Code, Cowork, API).
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# humanizer-ru: очеловечить русский AI-текст для Claude Code, Cursor, Codex и других AI-агентов
|
|
2
2
|
|
|
3
|
-
> Скилл для Claude. Убирает 64 признака нейросети в русском тексте: канцелярит, кальки, фингерпринты ChatGPT и Claude. Метит в то, что
|
|
3
|
+
> Скилл для Claude. Убирает 64 признака нейросети в русском тексте: канцелярит, кальки, фингерпринты ChatGPT и Claude. Метит в то, что измеряют GPTZero, DivEye, RuBERT: поднимает perplexity и burstiness. 21 жёстких банов, quad-pass аудит, калибровка под голос автора, eval-харнес с метриками до/после. Живое демо сканера на сайте.
|
|
4
4
|
|
|
5
5
|
> [English version](README.en.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)
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
>
|
|
22
22
|
> Три жёстких бана в одном предложении. [Полный разбор с режимом аудита ниже](#до-и-после).
|
|
23
23
|
|
|
24
|
+
🧪 **Живое демо:** [humanizer-ru.aifrontier.tech](https://humanizer-ru.aifrontier.tech/#audit). Вставьте свой текст, получите оценку чистоты 0-100 и подсветку найденных оборотов. Тот же сканер, что в скилле, считается в браузере.
|
|
25
|
+
|
|
26
|
+
**Быстрый старт** в Claude Code, две команды:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/plugin marketplace add ilyautov/humanizer-ru
|
|
30
|
+
/plugin install humanizer-ru@ilyautov-plugins
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Cursor, Copilot, Cline и другие агенты: `npx skills add https://github.com/ilyautov/humanizer-ru/tree/main/skills/humanizer-ru`. Claude.ai, Codex, DeepSeek Harness и раскатка на команду в [разделе «Установка»](#установка).
|
|
34
|
+
|
|
24
35
|
📖 **Документация и разборы:** [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).
|
|
25
36
|
|
|
26
37
|
## Зачем это нужно
|
|
@@ -61,6 +72,10 @@
|
|
|
61
72
|
|
|
62
73
|
**Что ловят детекторы:** секция с числами из исследований 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).
|
|
63
74
|
|
|
75
|
+
## Проверить текст без установки
|
|
76
|
+
|
|
77
|
+
На [главной странице сайта](https://humanizer-ru.aifrontier.tech/#audit) работает тот же сканер на JavaScript: вставьте текст, получите оценку чистоты от 0 до 100, подсветку найденных оборотов и команду установки для своей среды. Считается в браузере, текст никуда не отправляется. Правила экспортируются из `markers.py` скриптом `scripts/export_web_rules.py`, а паритет с Python проверяет `scripts/test_web_parity.py` в CI.
|
|
78
|
+
|
|
64
79
|
## Установка
|
|
65
80
|
|
|
66
81
|
Скилл разворачивается тремя путями: загрузкой в веб-интерфейс Claude.ai, централизованной раскаткой на команду или установкой в локальные агенты (Claude Code, Cowork, API).
|
|
@@ -224,6 +239,7 @@ dsh сканирует `~/.agents/skills` и `~/.dsh/skills` сам, перез
|
|
|
224
239
|
|
|
225
240
|
```bash
|
|
226
241
|
python skills/humanizer-ru/scripts/scan.py статья.txt --genre academic # ещё legal, fiction
|
|
242
|
+
python skills/humanizer-ru/scripts/scan.py стало.txt --before было.txt # чистота до/после + факт-замок
|
|
227
243
|
```
|
|
228
244
|
|
|
229
245
|
Зачем это нужно, показал прогон по внешнему размеченному корпусу AINL-Eval 2025
|
|
@@ -231,6 +247,11 @@ python skills/humanizer-ru/scripts/scan.py статья.txt --genre academic #
|
|
|
231
247
|
человеческих текстов против 26,1-35,8% машинных, то есть на научном тексте
|
|
232
248
|
каталог не различал автора вовсе. С жанровой поправкой ложные срабатывания на
|
|
233
249
|
людях падают до 4,2%. Цифры и метод: [eval/AINL-CALIBRATION.md](eval/AINL-CALIBRATION.md).
|
|
250
|
+
Третий корпус, [LLMTrace](https://huggingface.co/datasets/iitolstykh/LLMTrace_classification)
|
|
251
|
+
(14 763 русских текста, 30 генераторов, впервые GigaChat и YandexGPT), подтвердил
|
|
252
|
+
границы: на отзывах и историях длинное тире ставят 2-8% людей против 28-33%
|
|
253
|
+
машин, а в энциклопедических статьях наоборот. Разбор:
|
|
254
|
+
[eval/LLMTRACE-CALIBRATION.md](eval/LLMTRACE-CALIBRATION.md).
|
|
234
255
|
|
|
235
256
|
## До и после
|
|
236
257
|
|
|
@@ -266,7 +287,7 @@ GPTZero, Originality.ai, ZeroGPT и другие популярные детек
|
|
|
266
287
|
|
|
267
288
|
Даже лучший специализированный русский детектор (RuRoBERTa на бенчмарке AINL-Eval 2025) даёт около 86% точности: каждый седьмой вердикт мимо. И детекторы не обобщаются между доменами: обученный на научных текстах ошибается на блогах (verified, см. [SOURCES.md](SOURCES.md)).
|
|
268
289
|
|
|
269
|
-
**Что это значит для очеловечивания текста.** Гнаться за «обходом детектора» на русском значит подгонять текст под ненадёжный и постоянно меняющийся индикатор. Поэтому humanizer-ru оптимизирует не обман детектора, а **реальное качество текста**: убирает канцелярит, кальки и штампы, возвращает авторский голос и живой ритм. Это измеримые свойства языка (метрики в [`eval/`](eval/)), которые не зависят от того, насколько плох конкретный детектор. Заодно растут perplexity и burstiness: то, что детекторы (как умеют) и пытаются
|
|
290
|
+
**Что это значит для очеловечивания текста.** Гнаться за «обходом детектора» на русском значит подгонять текст под ненадёжный и постоянно меняющийся индикатор. Поэтому humanizer-ru оптимизирует не обман детектора, а **реальное качество текста**: убирает канцелярит, кальки и штампы, возвращает авторский голос и живой ритм. Это измеримые свойства языка (метрики в [`eval/`](eval/)), которые не зависят от того, насколько плох конкретный детектор. Заодно растут perplexity и burstiness: то, что детекторы (как умеют) и пытаются измерять.
|
|
270
291
|
|
|
271
292
|
Мы прогоняем детекторы прямо в eval-харнесе по контролю «заведомо человек» (дословные отрывки статей Википедии, написанные людьми задолго до ChatGPT). В последнем прогоне локальный LLM-детектор пометил как ИИ **3 из 3** человеческих текстов (false positive rate 100%), поставив им ту же вероятность «это AI», что и настоящим AI-текстам. Различающая способность близка к нулю. Методика, корпус и сырые прогоны: [eval/RESULTS.md](eval/RESULTS.md), секция «FP-аудит детекторов».
|
|
272
293
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "humanizer-ru",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.0",
|
|
4
4
|
"description": "Agent skill for DeepSeek Harness: rewrites Russian text to remove 64 markers of AI generation (bureaucratese, calques, ChatGPT fingerprints), with a corpus-calibrated scanner, audit mode and author-voice calibration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ilya Utov",
|
|
@@ -4,7 +4,7 @@ description: "Скилл для очеловечивания русскоязы
|
|
|
4
4
|
license: MIT
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# Humanizer-RU v3.
|
|
7
|
+
# Humanizer-RU v3.21.0
|
|
8
8
|
|
|
9
9
|
Ты редактор. Превращаешь стерильный AI-текст в живую русскую речь. Убираешь маркеры нейросети и возвращаешь в текст автора: мнение, ритм, характер.
|
|
10
10
|
|
|
@@ -150,7 +150,7 @@ license: MIT
|
|
|
150
150
|
|
|
151
151
|
**Шаг 3. Переписывание по разметке.** Работай по светофору из Шага 1: красные абзацы перепиши целиком, жёлтые правь точечно, зелёные не трогай. В красных и жёлтых абзацах: сначала убери HARD BANS, затем пройдись контрастным вычитанием (в каждом предложении замени самое предсказуемое слово на менее вероятное, но уместное). На каждый усилитель, вводное и образ гони двойной гейт сразу на этом шаге, не при финальной вычитке: тест на удаление (убрал, смысл не изменился = вода, паттерны #3/#40, удаляй) и тест на обналичивание (какой референт? паттерн #53). Одновременно добавляй голос и варьируй структуру. Сверяйся с паспортом голоса.
|
|
152
152
|
|
|
153
|
-
> **Факт-замок (приоритет над любым паттерном).** Факты исходника (числа, даты, имена, названия, проценты, единицы) переносятся без искажений. Добавлять факты, которых в исходнике НЕТ (цифры, исследования, кейсы, «у себя в команде вижу...»), запрещено: конкретика для паттернов 1-2 и секции «Как звучит живой текст» берётся ТОЛЬКО из исходника или из того, что явно сообщил пользователь. В исходнике нет конкретики? Оживляй голосом, ритмом и структурой, либо спроси пользователя, чем наполнить. Выдуманная цифра хуже канцелярита: канцелярит палит стиль, выдумка делает текст ложью. В примерах документации пометка «(факты автора)» в заголовке означает: конкретику в этом «После» дал автор текста, а не придумал редактор. Пример без такой пометки новых фактов не содержит, и гейт `scripts/check_examples.py` это проверяет.
|
|
153
|
+
> **Факт-замок (приоритет над любым паттерном).** Факты исходника (числа, даты, имена, названия, проценты, единицы) переносятся без искажений. Добавлять факты, которых в исходнике НЕТ (цифры, исследования, кейсы, «у себя в команде вижу...»), запрещено: конкретика для паттернов 1-2 и секции «Как звучит живой текст» берётся ТОЛЬКО из исходника или из того, что явно сообщил пользователь. В исходнике нет конкретики? Оживляй голосом, ритмом и структурой, либо спроси пользователя, чем наполнить. Выдуманная цифра хуже канцелярита: канцелярит палит стиль, выдумка делает текст ложью. В примерах документации пометка «(факты автора)» в заголовке означает: конкретику в этом «После» дал автор текста, а не придумал редактор. Пример без такой пометки новых фактов не содержит, и гейт `scripts/check_examples.py` это проверяет. Есть исходник и результат в файлах и можно запускать команды? Проверь замок механически: `python3 <папка скилла>/scripts/scan.py стало.txt --before было.txt`. Сканер печатает «было N, стало M» по чистоте и перечисляет числа, даты, имена, ссылки и код, которые пропали или появились. Новый факт без источника это ошибка правки, а не находка.
|
|
154
154
|
|
|
155
155
|
> **ВНИМАНИЕ: гомогенизация.** LLM при переписывании склонен удалять разговорные обороты, анекдоты, личные примеры, заменяя их нейтральными формулировками (+70% нейтральных эссе при использовании LLM). Даже промпт «только грамматика» меняет семантику. Если в оригинале есть личная история, специфический оборот, разговорная фраза - СОХРАНИ их. Не заменяй живое на нейтральное. Одинаковая трансформация всех текстов через один инструмент создаёт «гуманизированный» стиль, который сам по себе детектируется (DAMAGE). Варьируй подход: разные тексты переписывай по-разному.
|
|
156
156
|
|
|
@@ -265,7 +265,7 @@ davinci-003). Метод и полные таблицы: `eval/AINL-CALIBRATION.
|
|
|
265
265
|
|
|
266
266
|
### N. Дискурсный слой (59-64)
|
|
267
267
|
|
|
268
|
-
Слой выше слов: ЧТО рассказано и как устроено, а не какими словами. Категории A-M правят стиль, и стиль сам по себе сигнал сильный: в StoryScope (arXiv:2604.03136v6, COLM 2026: 10 272 промпта, человек и пять LLM, 61 608 историй по ~5000 слов, 304 фичи на текст) классификатор на одних только 39 стилевых фичах даёт 85.8 macro-F1, а сырые стилометрические бейзлайны на ручных признаках и вовсе 99.8. Слабое место стиля не в силе, а в хрупкости: раздел 4.2 статьи прямо исходит из того, что стилевые детекторы ломаются от перефраза и лёгкой правки, то есть ровно от того, что делает каталог выше. Ценность дискурсного слоя в другом: он не зависит от стиля и переживает стилевую перезапись. Нарративный классификатор статьи держит 93.9% macro-F1 после полной span-level перезаписи 278 историй (клише, избыточная экспозиция, «фиолетовая проза») против 95.5% на тех же историях до правки: минус 1.6 пункта, потому что правка прозы не трогает структурных решений - каузальной линейности, явной проговорённости темы, избытка сенсорики. Отсюда практический вывод, ради которого категория и добавлена: одна стилевая правка текст не очищает, слой формы остаётся нетронутым. Раскладка фич: из 304 фич 39 относятся к стилевому измерению и ещё 8 помечены как связанные со стилем, всего исключено 47; нарративная модель работает на оставшихся 257 и даёт 93.2% macro-F1 в задаче человек/ИИ, в 2.8 пункта от полной модели на 304 фичах (96.0). Категория дополняет A-M, не заменяет: сначала стилевой слой, потом дискурсный. Граница применимости: статья
|
|
268
|
+
Слой выше слов: ЧТО рассказано и как устроено, а не какими словами. Категории A-M правят стиль, и стиль сам по себе сигнал сильный: в StoryScope (arXiv:2604.03136v6, COLM 2026: 10 272 промпта, человек и пять LLM, 61 608 историй по ~5000 слов, 304 фичи на текст) классификатор на одних только 39 стилевых фичах даёт 85.8 macro-F1, а сырые стилометрические бейзлайны на ручных признаках и вовсе 99.8. Слабое место стиля не в силе, а в хрупкости: раздел 4.2 статьи прямо исходит из того, что стилевые детекторы ломаются от перефраза и лёгкой правки, то есть ровно от того, что делает каталог выше. Ценность дискурсного слоя в другом: он не зависит от стиля и переживает стилевую перезапись. Нарративный классификатор статьи держит 93.9% macro-F1 после полной span-level перезаписи 278 историй (клише, избыточная экспозиция, «фиолетовая проза») против 95.5% на тех же историях до правки: минус 1.6 пункта, потому что правка прозы не трогает структурных решений - каузальной линейности, явной проговорённости темы, избытка сенсорики. Отсюда практический вывод, ради которого категория и добавлена: одна стилевая правка текст не очищает, слой формы остаётся нетронутым. Раскладка фич: из 304 фич 39 относятся к стилевому измерению и ещё 8 помечены как связанные со стилем, всего исключено 47; нарративная модель работает на оставшихся 257 и даёт 93.2% macro-F1 в задаче человек/ИИ, в 2.8 пункта от полной модели на 304 фичах (96.0). Категория дополняет A-M, не заменяет: сначала стилевой слой, потом дискурсный. Граница применимости: статья измеряла беллетристику ~5000 слов; перенос на посты, кейсы и письма - экстраполяция механизма, статьёй не проверенная. Шесть паттернов ниже - выборка дискурсных признаков, пригодных для правки руками; ранжирования «самых сильных» статья не даёт, и весь дискурс (257 фич) они не покрывают. Паттерны 61 и 64 - предложения автору, не автоправки.
|
|
269
269
|
|
|
270
270
|
**59. Явная мораль в финале.** ИИ-нарратор проговаривает тему истории прямым текстом в 77% случаев, человек в 52%. Финал с уроком отдельным предложением: «эта история учит нас...», «мораль проста...», «и тогда она поняла, что главное...». Смысл уже виден из событий, а финал его всё равно озвучивает. Вырежи озвучку: закончи действием, деталью или репликой, читатель соберёт вывод сам. Не то же, что #4 (формульные выводы): там клише-фразы, здесь смысловой уровень, мораль палит и без клише. Шаблонные формулы морали ловит сканер (категория «Финальная мораль»); мораль без шаблонных слов оценивай по прочтении.
|
|
271
271
|
|
|
@@ -283,7 +283,7 @@ davinci-003). Метод и полные таблицы: `eval/AINL-CALIBRATION.
|
|
|
283
283
|
До: «Игорь — опытный, методичный разработчик, привыкший всё держать под контролем.»
|
|
284
284
|
После: «Игорь откатил релиз, никого не спросив.»
|
|
285
285
|
|
|
286
|
-
**61. Эмоция только через тело.** Контринтуитивный маркер: телесной метафорой злоупотребляет ИИ, не человек.
|
|
286
|
+
**61. Эмоция только через тело.** Контринтуитивный маркер: телесной метафорой злоупотребляет ИИ, не человек. Измеряется это одной фичей с четырьмя взаимоисключающими вариантами («доминирующий способ подачи эмоции»), то есть речь о том, какой способ в истории ГЛАВНЫЙ, а не о доле эмоций. Телесная подача доминирует в 81% историй ИИ и в 38% человеческих: «сердце сжалось», «ком в горле», «холодок по спине». Прямое называние чувства доминирует, наоборот, чаще у людей: 29% против 8% у ИИ. Писать «почти каждая эмоция подана через тело» из этих цифр нельзя, они про другое. Люди спокойно пишут «мне было страшно» и «до сих пор стыдно»; модель прячет эмоцию в тело; похоже на переусердствование с правилом «show, don't tell», но причин статья не разбирает. Сигнал: по всему тексту чувства поданы исключительно телом и ни разу не названы. Это предложение автору, не автоправка: что именно чувствовал герой, знает автор. Предложи назвать одну-две эмоции прямо, решение за ним.
|
|
287
287
|
|
|
288
288
|
**62. Бесшовная причинная цепь.** У ИИ события чаще плотно вытекают одно из другого: непрерывность каузальной цепи 4.20 против 3.92 у человека. Это средние по шкале 1-5, сдвиг на 0.28 пункта, а не два разных мира: признак вспомогательный, «ИИ всегда пишет без швов» отсюда не следует. Живая история содержит швы: совпадение, случайность, ветку, которая никуда не привела. Проследи цепь событий целиком; если на весь текст ни одного провисания и ни одной случайности, это сигнал. Лечится одним швом: событие без подготовки, деталь, которая не выстрелила. Родня #44, но зеркально: там гладкость фраз-связок, здесь гладкость самих событий. С #34 не конфликтует: там абзацы рассуждения связаны слабо, здесь события истории связаны слишком хорошо. Автосканером не ловится, оценка только по прочтении.
|
|
289
289
|
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
import re
|
|
15
14
|
from dataclasses import dataclass, replace
|
|
16
15
|
|
|
17
16
|
from .burstiness import RhythmStats, rhythm, rhythm_verdict
|
|
@@ -39,6 +38,10 @@ __all__ = [
|
|
|
39
38
|
"structure_stats",
|
|
40
39
|
"scan_hard_bans",
|
|
41
40
|
"scan_markers",
|
|
41
|
+
"mask_foreign",
|
|
42
|
+
"strip_foreign",
|
|
43
|
+
"GAP",
|
|
44
|
+
"QUOTE_MAX_WORDS",
|
|
42
45
|
]
|
|
43
46
|
|
|
44
47
|
|
|
@@ -71,56 +74,13 @@ class Report:
|
|
|
71
74
|
|
|
72
75
|
|
|
73
76
|
# --- Код и цитаты не текст автора ------------------------------------------
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
#
|
|
82
|
-
# Замена сохраняет длину и переводы строк, чтобы номера строк в отчёте
|
|
83
|
-
# совпадали с файлом; заглушка не буква, поэтому фразовые регексы через неё
|
|
84
|
-
# не склеиваются («От «В современном мире…» до клише» не станет «От до»).
|
|
85
|
-
GAP = "·"
|
|
86
|
-
QUOTE_MAX_WORDS = 12
|
|
87
|
-
# Ограда блока кода стоит только в начале строки (CommonMark): три обратные
|
|
88
|
-
# кавычки внутри строки кода не закрывают блок. Ленивый `(?s)```.*?``` ` на
|
|
89
|
-
# `x = "```"` съезжал по границам и уносил в код следующий абзац прозы.
|
|
90
|
-
_FENCED = re.compile(r"(?ms)^[ \t]{0,3}(`{3,}|~{3,})[^\n]*\n.*?^[ \t]{0,3}\1[ \t]*$")
|
|
91
|
-
_INLINE_CODE = re.compile(r"`[^`\n]*`")
|
|
92
|
-
_QUOTE = re.compile(r"«[^«»]*»")
|
|
93
|
-
# Markdown-цитата через «>» это чужой текст целиком: разбор плохого примера
|
|
94
|
-
# получал «рерайт» за штампы, которые автор как раз критикует.
|
|
95
|
-
_BLOCKQUOTE = re.compile(r"(?m)^[ \t]*>.*$")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _blank(match: re.Match[str]) -> str:
|
|
99
|
-
return re.sub(r"[^\n]", GAP, match.group(0))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _blank_short_quote(match: re.Match[str]) -> str:
|
|
103
|
-
inner = match.group(0)[1:-1]
|
|
104
|
-
if len(inner.split()) <= QUOTE_MAX_WORDS:
|
|
105
|
-
return _blank(match)
|
|
106
|
-
return match.group(0)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def mask_code_and_quotes(text: str) -> str:
|
|
110
|
-
"""Текст для лексического сканера: код, markdown-цитаты и короткие цитаты
|
|
111
|
-
в ёлочках заглушены, смещения и номера строк сохранены."""
|
|
112
|
-
text = _FENCED.sub(_blank, text)
|
|
113
|
-
text = _INLINE_CODE.sub(_blank, text)
|
|
114
|
-
text = _BLOCKQUOTE.sub(_blank, text)
|
|
115
|
-
return _QUOTE.sub(_blank_short_quote, text)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def strip_code(text: str) -> str:
|
|
119
|
-
"""Текст для ритма и морфологии: код и markdown-цитаты удалены, проза
|
|
120
|
-
оставлена как есть (ёлочки не трогаем, они внутри предложений автора)."""
|
|
121
|
-
text = _FENCED.sub("", text)
|
|
122
|
-
text = _INLINE_CODE.sub("", text)
|
|
123
|
-
return _BLOCKQUOTE.sub("", text)
|
|
77
|
+
# Разбор границ Markdown живёт в markdown.py (один построчный проход по
|
|
78
|
+
# CommonMark и таблица тестов), здесь только две проекции текста.
|
|
79
|
+
from .markdown import GAP, QUOTE_MAX_WORDS, mask_foreign, strip_foreign # noqa: E402
|
|
80
|
+
from .facts import Facts, FactsDiff, diff_facts, extract_facts, facts_verdict # noqa: E402
|
|
81
|
+
|
|
82
|
+
mask_code_and_quotes = mask_foreign
|
|
83
|
+
strip_code = strip_foreign
|
|
124
84
|
|
|
125
85
|
|
|
126
86
|
def analyze(text: str) -> Report:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Ритм и типографика: то, что скилл велит
|
|
1
|
+
"""Ритм и типографика: то, что скилл велит измерять дисперсией, а не на глаз.
|
|
2
2
|
|
|
3
3
|
Закрывает пункты чеклиста «вариативность длины предложений — не средняя, а
|
|
4
4
|
ДИСПЕРСИЯ» и «ноль длинных тире». Burstiness тут — статистический прокси, не сам
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Факт-замок как скрипт: что из фактов исходника дожило до результата.
|
|
2
|
+
|
|
3
|
+
Скилл обещает переносить числа, даты, имена, названия и ссылки без искажений и
|
|
4
|
+
не добавлять новых. Инструкция модели это одно, проверка другое: этот модуль
|
|
5
|
+
вынимает из двух текстов «факты» и сравнивает множества. Не семантика и не
|
|
6
|
+
фактчек: только сохранность того, что уже было, и появление того, чего не было.
|
|
7
|
+
|
|
8
|
+
Классы фактов:
|
|
9
|
+
- число: любой токен с цифрой (проценты, годы, суммы), нормализован до цифр;
|
|
10
|
+
- число словами: крупные числительные («сорок», «тысяча»), их не пересказывают;
|
|
11
|
+
- месяц: названия месяцев как замена дате;
|
|
12
|
+
- ссылка: URL и адреса вида domain.tld/path;
|
|
13
|
+
- код: содержимое инлайн-кода в бэктиках;
|
|
14
|
+
- имя: слово с заглавной не в начале предложения (с pymorphy3 точнее: теги
|
|
15
|
+
Name/Surn/Geox/Orgn/Patr и незнакомые словарю слова), плюс любая латиница с
|
|
16
|
+
заглавной («Excel», «GPT-4»).
|
|
17
|
+
|
|
18
|
+
Мелкие количества («два», «половина», «вдвое») факты мягкие: в русском они
|
|
19
|
+
идиоматичны («с одной стороны») и обычно пересказывают число исходника. Они
|
|
20
|
+
считаются, но не валят проверку.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import pymorphy3
|
|
30
|
+
|
|
31
|
+
_MORPH = pymorphy3.MorphAnalyzer()
|
|
32
|
+
except ImportError: # без словаря сравниваем по основе, грубее, но работает
|
|
33
|
+
_MORPH = None
|
|
34
|
+
|
|
35
|
+
PROPER_TAGS = {"Name", "Surn", "Geox", "Orgn", "Patr"}
|
|
36
|
+
|
|
37
|
+
MONTH_RE = re.compile(
|
|
38
|
+
r"\b(январ|феврал|март|апрел|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-я]*\b",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
URL_RE = re.compile(r"https?://[^\s)>\]»]+|\b[a-z0-9-]+\.(?:ru|com|org|net|io|tech|dev|ai|su|рф)(?:/[^\s)>\]»]*)?",
|
|
42
|
+
re.IGNORECASE)
|
|
43
|
+
CODE_RE = re.compile(r"`([^`\n]+)`")
|
|
44
|
+
TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\d\-]*|[А-Яа-яЁё][А-Яа-яЁё\-]*|\d[\d.,:/-]*")
|
|
45
|
+
|
|
46
|
+
SOFT_QUANTITIES = {
|
|
47
|
+
"один", "два", "две", "три", "оба", "обе", "пара",
|
|
48
|
+
"первый", "второй", "третий",
|
|
49
|
+
"вдвое", "втрое", "дважды", "трижды",
|
|
50
|
+
"половина", "треть", "четверть", "полтора",
|
|
51
|
+
}
|
|
52
|
+
HARD_NUMERALS = {
|
|
53
|
+
"четыре", "пять", "шесть", "семь", "восемь", "девять", "десять",
|
|
54
|
+
"одиннадцать", "двенадцать", "пятнадцать", "двадцать", "тридцать",
|
|
55
|
+
"сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят",
|
|
56
|
+
"девяносто", "сто", "двести", "триста", "тысяча", "миллион", "миллиард",
|
|
57
|
+
"десяток", "сотня", "дюжина",
|
|
58
|
+
}
|
|
59
|
+
# Одна сущность под разными именами не считается новым фактом.
|
|
60
|
+
ALIASES = {
|
|
61
|
+
"ai": ("искусственный", "интеллект", "ии", "нейросеть", "модель"),
|
|
62
|
+
"ии": ("искусственный", "интеллект", "ai", "нейросеть", "модель"),
|
|
63
|
+
"llm": ("модель", "нейросеть", "ai", "ии"),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Facts:
|
|
69
|
+
hard: set[str] = field(default_factory=set) # "число:13", "имя:стэнфорд", "ссылка:…"
|
|
70
|
+
soft: set[str] = field(default_factory=set) # "два", "половина"
|
|
71
|
+
words: set[str] = field(default_factory=set) # все слова в нормальной форме, для алиасов
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class FactsDiff:
|
|
76
|
+
lost: list[str] # были в исходнике, пропали
|
|
77
|
+
added: list[str] # появились в результате, в исходнике не было
|
|
78
|
+
kept: int # жёстких фактов исходника дожило
|
|
79
|
+
soft_added: list[str]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def ok(self) -> bool:
|
|
83
|
+
return not self.added
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict:
|
|
86
|
+
return {"ok": self.ok, "lost": self.lost, "added": self.added,
|
|
87
|
+
"kept": self.kept, "soft_added": self.soft_added}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _norm(word: str) -> str:
|
|
91
|
+
low = word.lower().replace("ё", "е").strip("-")
|
|
92
|
+
if _MORPH is None:
|
|
93
|
+
return low[:5]
|
|
94
|
+
return _MORPH.parse(low)[0].normal_form.replace("ё", "е")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sentence_starts(text: str) -> set[int]:
|
|
98
|
+
starts = {0}
|
|
99
|
+
for m in re.finditer(r"[.!?…]\s+|^[>\-*]\s+|«|\n", text):
|
|
100
|
+
starts.add(m.end())
|
|
101
|
+
return starts
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_proper(word: str, at_start: bool) -> bool:
|
|
105
|
+
if not word[:1].isupper():
|
|
106
|
+
return False
|
|
107
|
+
if word.isascii():
|
|
108
|
+
return True
|
|
109
|
+
if _MORPH is None:
|
|
110
|
+
return not at_start
|
|
111
|
+
p = _MORPH.parse(word.lower())[0]
|
|
112
|
+
if PROPER_TAGS & set(p.tag.grammemes):
|
|
113
|
+
return True
|
|
114
|
+
if not p.is_known:
|
|
115
|
+
return True
|
|
116
|
+
return not at_start
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_facts(text: str) -> Facts:
|
|
120
|
+
f = Facts()
|
|
121
|
+
for m in URL_RE.finditer(text):
|
|
122
|
+
url = re.sub(r"^https?://(?:www\.)?", "", m.group(0).rstrip(".,;:").lower()).rstrip("/")
|
|
123
|
+
f.hard.add("ссылка:" + url)
|
|
124
|
+
for m in CODE_RE.finditer(text):
|
|
125
|
+
f.hard.add("код:" + m.group(1).strip())
|
|
126
|
+
body = CODE_RE.sub(" ", URL_RE.sub(" ", text))
|
|
127
|
+
for m in MONTH_RE.finditer(body):
|
|
128
|
+
f.hard.add("месяц:" + m.group(1).lower())
|
|
129
|
+
starts = _sentence_starts(body)
|
|
130
|
+
for m in TOKEN_RE.finditer(body):
|
|
131
|
+
tok = m.group(0)
|
|
132
|
+
if tok[0].isdigit():
|
|
133
|
+
digits = re.sub(r"\D", "", tok)
|
|
134
|
+
if digits:
|
|
135
|
+
f.hard.add("число:" + (digits.lstrip("0") or "0"))
|
|
136
|
+
continue
|
|
137
|
+
norm = _norm(tok)
|
|
138
|
+
f.words.add(norm)
|
|
139
|
+
if norm in SOFT_QUANTITIES:
|
|
140
|
+
f.soft.add(norm)
|
|
141
|
+
elif norm in HARD_NUMERALS:
|
|
142
|
+
f.hard.add("число словами:" + norm)
|
|
143
|
+
elif _is_proper(tok, any(abs(m.start() - s) <= 1 for s in starts)):
|
|
144
|
+
f.hard.add("имя:" + norm)
|
|
145
|
+
return f
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _alias_covered(fact: str, before_words: set[str]) -> bool:
|
|
149
|
+
if not fact.startswith("имя:"):
|
|
150
|
+
return False
|
|
151
|
+
name = fact[4:]
|
|
152
|
+
return any(alias in before_words for alias in ALIASES.get(name, ()))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def diff_facts(before: str, after: str) -> FactsDiff:
|
|
156
|
+
b, a = extract_facts(before), extract_facts(after)
|
|
157
|
+
lost = sorted(b.hard - a.hard)
|
|
158
|
+
added = sorted(x for x in a.hard - b.hard if not _alias_covered(x, b.words))
|
|
159
|
+
return FactsDiff(lost=lost, added=added, kept=len(b.hard & a.hard),
|
|
160
|
+
soft_added=sorted(a.soft - b.soft))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def facts_verdict(d: FactsDiff) -> str:
|
|
164
|
+
if d.added:
|
|
165
|
+
return f"✗ новых фактов без источника: {len(d.added)} (выдумка хуже канцелярита)"
|
|
166
|
+
if d.lost:
|
|
167
|
+
return f"⚠ факты исходника на месте, но {len(d.lost)} потеряно: проверьте, намеренно ли"
|
|
168
|
+
return f"✓ факт-замок цел: {d.kept} фактов исходника перенесено, новых нет"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Границы чужого текста в Markdown: код, цитаты, короткие «ёлочки».
|
|
2
|
+
|
|
3
|
+
Сканер измеряет слова автора. Листинг кода, markdown-цитата через «>» и короткая
|
|
4
|
+
цитата в ёлочках это чужие слова, их баны и маркеры не в счёт. Два релиза
|
|
5
|
+
подряд границы чинились регулярками по одному примеру, и каждый раз соседний
|
|
6
|
+
вариант той же ошибки оставался (ограда внутри строки кода, закрытие оградой
|
|
7
|
+
длиннее открывающей, продолжение цитаты без «>»). Здесь один построчный разбор
|
|
8
|
+
состояния по правилам CommonMark и таблица тестов на варианты в
|
|
9
|
+
scripts/test_markers.py.
|
|
10
|
+
|
|
11
|
+
Две проекции одного текста:
|
|
12
|
+
- lexical: чужое заменено заглушкой GAP посимвольно, переводы строк на месте.
|
|
13
|
+
Смещения и номера строк совпадают с исходником, фразовые регексы не
|
|
14
|
+
склеивают слова через заглушку (она не буква).
|
|
15
|
+
- prose: чужое вырезано целиком. Для ритма, морфологии и структуры: заглушки
|
|
16
|
+
ломали бы длину предложений и подсчёт абзацев.
|
|
17
|
+
|
|
18
|
+
Правила:
|
|
19
|
+
- Ограда кода: 3+ обратных кавычек или тильд с отступом до 3 пробелов в начале
|
|
20
|
+
строки. Закрывает ограда того же символа НЕ КОРОЧЕ открывающей. Кавычки внутри
|
|
21
|
+
строки кода оградой не являются. Незакрытая ограда не маскируется вовсе:
|
|
22
|
+
для сканера ложное срабатывание дешевле спрятанного абзаца.
|
|
23
|
+
- Цитата: строка с «>» в начале (отступ до 3 пробелов). Ленивое продолжение:
|
|
24
|
+
следующие непустые строки без «>» остаются цитатой, пока не встретится
|
|
25
|
+
пустая строка, заголовок, пункт списка или ограда.
|
|
26
|
+
- Инлайн-код: серия обратных кавычек закрывается серией той же длины в
|
|
27
|
+
пределах строки (`x`, ``a`b``).
|
|
28
|
+
- Короткая цитата в ёлочках (до QUOTE_MAX_WORDS слов) заглушается только в
|
|
29
|
+
lexical: так цитируют слово или оборот. Длинная остаётся под сканером, это
|
|
30
|
+
прямая речь или пересказ, её маркеры на совести автора. В prose ёлочки не
|
|
31
|
+
трогаем, они внутри предложений автора.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
|
|
38
|
+
GAP = "·"
|
|
39
|
+
QUOTE_MAX_WORDS = 12
|
|
40
|
+
|
|
41
|
+
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
42
|
+
_FENCE_CLOSE = re.compile(r"^ {0,3}(`{3,}|~{3,})\s*$")
|
|
43
|
+
_QUOTE_LINE = re.compile(r"^ {0,3}>")
|
|
44
|
+
# Строки, которые прерывают ленивое продолжение цитаты (CommonMark 5.1):
|
|
45
|
+
# заголовок, пункт списка, горизонтальная линия, ограда.
|
|
46
|
+
_BLOCK_START = re.compile(r"^ {0,3}(?:#{1,6}\s|[-*+]\s|\d{1,9}[.)]\s|(?:-{3,}|\*{3,}|_{3,})\s*$|`{3,}|~{3,})")
|
|
47
|
+
_INLINE_CODE = re.compile(r"(`+)(?!`)([^`\n]+?)\1(?!`)")
|
|
48
|
+
_QUOTE_MARKS = re.compile(r"«[^«»]*»")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _blank(s: str) -> str:
|
|
52
|
+
return re.sub(r"[^\n]", GAP, s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fence_ranges(lines: list[str]) -> list[tuple[int, int]]:
|
|
56
|
+
"""Пары (первая, последняя) строк закрытых блоков кода, включая ограды."""
|
|
57
|
+
ranges: list[tuple[int, int]] = []
|
|
58
|
+
i = 0
|
|
59
|
+
while i < len(lines):
|
|
60
|
+
m = _FENCE_OPEN.match(lines[i])
|
|
61
|
+
# Инфо-строка ограды из кавычек не может содержать кавычки (CommonMark).
|
|
62
|
+
if not m or (m.group(1)[0] == "`" and "`" in m.group(2)):
|
|
63
|
+
i += 1
|
|
64
|
+
continue
|
|
65
|
+
ch, need = m.group(1)[0], len(m.group(1))
|
|
66
|
+
j = i + 1
|
|
67
|
+
while j < len(lines):
|
|
68
|
+
c = _FENCE_CLOSE.match(lines[j])
|
|
69
|
+
if c and c.group(1)[0] == ch and len(c.group(1)) >= need:
|
|
70
|
+
break
|
|
71
|
+
j += 1
|
|
72
|
+
if j >= len(lines):
|
|
73
|
+
i += 1 # незакрытая ограда: обычная строка
|
|
74
|
+
continue
|
|
75
|
+
ranges.append((i, j))
|
|
76
|
+
i = j + 1
|
|
77
|
+
return ranges
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _quote_lines(lines: list[str], in_code: set[int]) -> set[int]:
|
|
81
|
+
quoted: set[int] = set()
|
|
82
|
+
inside = False
|
|
83
|
+
for i, line in enumerate(lines):
|
|
84
|
+
if i in in_code:
|
|
85
|
+
inside = False
|
|
86
|
+
continue
|
|
87
|
+
if _QUOTE_LINE.match(line):
|
|
88
|
+
inside = True
|
|
89
|
+
quoted.add(i)
|
|
90
|
+
elif inside and line.strip() and not _BLOCK_START.match(line):
|
|
91
|
+
quoted.add(i) # ленивое продолжение абзаца цитаты
|
|
92
|
+
else:
|
|
93
|
+
inside = False
|
|
94
|
+
return quoted
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _classify(text: str) -> tuple[list[str], set[int], set[int]]:
|
|
98
|
+
lines = text.split("\n")
|
|
99
|
+
in_code: set[int] = set()
|
|
100
|
+
for a, b in _fence_ranges(lines):
|
|
101
|
+
in_code.update(range(a, b + 1))
|
|
102
|
+
return lines, in_code, _quote_lines(lines, in_code)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _blank_short_quote(m: re.Match[str]) -> str:
|
|
106
|
+
inner = m.group(0)[1:-1]
|
|
107
|
+
return _blank(m.group(0)) if len(inner.split()) <= QUOTE_MAX_WORDS else m.group(0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def mask_foreign(text: str) -> str:
|
|
111
|
+
"""lexical: код, цитаты и короткие ёлочки заглушены, смещения сохранены."""
|
|
112
|
+
lines, in_code, quoted = _classify(text)
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
for i, line in enumerate(lines):
|
|
115
|
+
if i in in_code or i in quoted:
|
|
116
|
+
out.append(_blank(line))
|
|
117
|
+
else:
|
|
118
|
+
out.append(_INLINE_CODE.sub(lambda m: _blank(m.group(0)), line))
|
|
119
|
+
return _QUOTE_MARKS.sub(_blank_short_quote, "\n".join(out))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def strip_foreign(text: str) -> str:
|
|
123
|
+
"""prose: код и цитаты вырезаны, инлайн-код удалён, ёлочки на месте."""
|
|
124
|
+
lines, in_code, quoted = _classify(text)
|
|
125
|
+
kept = [_INLINE_CODE.sub("", line) for i, line in enumerate(lines)
|
|
126
|
+
if i not in in_code and i not in quoted]
|
|
127
|
+
return "\n".join(kept)
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Это машинная половина режима «Аудит». Для полного очеловечивания (семантика,
|
|
10
10
|
голос, рерайт) нужен сам скилл — этот сканер только подсвечивает грепабельные
|
|
11
|
-
маркеры и считает метрики, которые LLM не умеет
|
|
11
|
+
маркеры и считает метрики, которые LLM не умеет измерять на глаз.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
@@ -22,7 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
23
|
|
|
24
24
|
try:
|
|
25
|
-
from humanizer_metrics import analyze, cleanliness_score
|
|
25
|
+
from humanizer_metrics import analyze, cleanliness_score, diff_facts, facts_verdict
|
|
26
26
|
from humanizer_metrics.burstiness import rhythm_verdict
|
|
27
27
|
from humanizer_metrics.markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES,
|
|
28
28
|
GENRES, effective_hard_bans, marker_verdict,
|
|
@@ -69,6 +69,10 @@ def main() -> int:
|
|
|
69
69
|
ap = argparse.ArgumentParser(description="Детерминированный сканер AI-маркеров (humanizer-ru)")
|
|
70
70
|
ap.add_argument("source", help="файл с текстом или '-' для stdin")
|
|
71
71
|
ap.add_argument("--json", action="store_true", help="вывод в JSON")
|
|
72
|
+
ap.add_argument("--before", metavar="ФАЙЛ",
|
|
73
|
+
help="исходник до правки: сканер добавит «было/стало» по чистоте и "
|
|
74
|
+
"проверит факт-замок (числа, даты, имена, ссылки, код). "
|
|
75
|
+
"Новый факт, которого не было в исходнике, даёт exit 2")
|
|
72
76
|
ap.add_argument("--genre", choices=GENRES, default="marketing",
|
|
73
77
|
help="жанр текста: снимает маркеры, законные для регистра "
|
|
74
78
|
"(academic, legal, fiction). По умолчанию marketing: "
|
|
@@ -87,6 +91,9 @@ def main() -> int:
|
|
|
87
91
|
rep = analyze(text)
|
|
88
92
|
genre = args.genre
|
|
89
93
|
sc = cleanliness_score(rep, genre)
|
|
94
|
+
before_text = _read(args.before) if args.before else None
|
|
95
|
+
before_sc = cleanliness_score(analyze(before_text), genre) if before_text else None
|
|
96
|
+
fdiff = diff_facts(before_text, text) if before_text is not None else None
|
|
90
97
|
# Частотные баны («Является» до порога 1/500 слов) не валят exit и не
|
|
91
98
|
# показываются как ⛔ — они остаются в мягких маркерах.
|
|
92
99
|
bans = effective_hard_bans(rep.hard_bans, rep.rhythm.words)
|
|
@@ -111,7 +118,12 @@ def main() -> int:
|
|
|
111
118
|
out["muted_by_genre"] = {"hard_bans": muted_bans, "markers": muted_soft}
|
|
112
119
|
if _cyrillic_share(text) < 0.3:
|
|
113
120
|
out["warning"] = "текст не похож на русский, метрики не применимы"
|
|
121
|
+
if fdiff is not None:
|
|
122
|
+
out["before"] = {"source": args.before, "score": before_sc.as_dict()}
|
|
123
|
+
out["facts"] = fdiff.as_dict()
|
|
114
124
|
print(json.dumps(out, ensure_ascii=False, indent=2))
|
|
125
|
+
if fdiff is not None and not fdiff.ok:
|
|
126
|
+
return 2
|
|
115
127
|
return 1 if bans else 0
|
|
116
128
|
|
|
117
129
|
print(f"=== humanizer-ru scan: {args.source} ===")
|
|
@@ -124,7 +136,10 @@ def main() -> int:
|
|
|
124
136
|
print("⚠ Текст не похож на русский: скилл и метрики рассчитаны на русский "
|
|
125
137
|
"язык, отчёт ниже не показателен.\n")
|
|
126
138
|
|
|
127
|
-
|
|
139
|
+
if before_sc is not None:
|
|
140
|
+
print(f"ЧИСТОТА: было {before_sc.score}, стало {sc.score}/100 [{sc.band}]")
|
|
141
|
+
else:
|
|
142
|
+
print(f"ЧИСТОТА: {sc.score}/100 [{sc.band}]")
|
|
128
143
|
print(" (≥85 чисто · 60-84 точечная правка · <60 рерайт)")
|
|
129
144
|
if sc.penalties:
|
|
130
145
|
for reason, pts in sc.penalties:
|
|
@@ -169,6 +184,20 @@ def main() -> int:
|
|
|
169
184
|
print("Структура (уровень документа):")
|
|
170
185
|
print(f" {structure_verdict(rep.structure)}")
|
|
171
186
|
|
|
187
|
+
if fdiff is not None:
|
|
188
|
+
print()
|
|
189
|
+
print(f"Факт-замок (против {args.before}):")
|
|
190
|
+
print(f" {facts_verdict(fdiff)}")
|
|
191
|
+
for x in fdiff.added:
|
|
192
|
+
print(f" ✗ новое: {x}")
|
|
193
|
+
for x in fdiff.lost:
|
|
194
|
+
print(f" ⚠ потеряно: {x}")
|
|
195
|
+
if fdiff.soft_added:
|
|
196
|
+
print(f" ℹ мелкие количества появились: {', '.join(fdiff.soft_added)} "
|
|
197
|
+
"(идиомы и пересказ чисел не считаются, проверьте глазами)")
|
|
198
|
+
if not fdiff.ok:
|
|
199
|
+
return 2
|
|
200
|
+
|
|
172
201
|
# Exit code: ненулевой, если есть HARD BANS — удобно для CI/pre-commit.
|
|
173
202
|
return 1 if bans else 0
|
|
174
203
|
|