humanizer-ru 3.19.1 → 3.20.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.19.1-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Version](https://img.shields.io/badge/version-3.20.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)
@@ -22,6 +22,10 @@ Claude Code / Cowork plugin. Kills AI smell in Russian text. The English [humani
22
22
 
23
23
  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
24
 
25
+ ## Try it without installing
26
+
27
+ 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.
28
+
25
29
  ## Install
26
30
 
27
31
  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. Метит в то, что меряют GPTZero, DivEye, RuBERT: поднимает perplexity и burstiness. 21 жёстких банов, quad-pass аудит, калибровка под голос автора, eval-харнес с метриками до/после.
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: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
- [![Версия](https://img.shields.io/badge/версия-3.19.1-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Версия](https://img.shields.io/badge/версия-3.20.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)
@@ -61,6 +61,10 @@
61
61
 
62
62
  **Что ловят детекторы:** секция с числами из исследований 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
63
 
64
+ ## Проверить текст без установки
65
+
66
+ На [главной странице сайта](https://humanizer-ru.aifrontier.tech/#audit) работает тот же сканер на JavaScript: вставьте текст, получите оценку чистоты от 0 до 100, подсветку найденных оборотов и команду установки для своей среды. Считается в браузере, текст никуда не отправляется. Правила экспортируются из `markers.py` скриптом `scripts/export_web_rules.py`, а паритет с Python проверяет `scripts/test_web_parity.py` в CI.
67
+
64
68
  ## Установка
65
69
 
66
70
  Скилл разворачивается тремя путями: загрузкой в веб-интерфейс Claude.ai, централизованной раскаткой на команду или установкой в локальные агенты (Claude Code, Cowork, API).
@@ -133,12 +137,6 @@ cp -r humanizer-ru/skills/humanizer-ru ~/.codex/skills/
133
137
  вызов: `$humanizer-ru` или автоматически по триггер-фразам («очеловечь»,
134
138
  «убери канцелярит»). Проектная установка: та же папка в `.codex/skills/` внутри репозитория.
135
139
 
136
- Или клонированием:
137
-
138
- ```bash
139
- git clone https://github.com/ilyautov/humanizer-ru.git ~/.claude/skills/humanizer-ru
140
- ```
141
-
142
140
  ### Проверка установочного пакета
143
141
 
144
142
  Перед релизом соберите ZIP тем же скриптом, который использует CI, затем
@@ -272,7 +270,7 @@ GPTZero, Originality.ai, ZeroGPT и другие популярные детек
272
270
 
273
271
  Даже лучший специализированный русский детектор (RuRoBERTa на бенчмарке AINL-Eval 2025) даёт около 86% точности: каждый седьмой вердикт мимо. И детекторы не обобщаются между доменами: обученный на научных текстах ошибается на блогах (verified, см. [SOURCES.md](SOURCES.md)).
274
272
 
275
- **Что это значит для очеловечивания текста.** Гнаться за «обходом детектора» на русском значит подгонять текст под ненадёжный и постоянно меняющийся индикатор. Поэтому humanizer-ru оптимизирует не обман детектора, а **реальное качество текста**: убирает канцелярит, кальки и штампы, возвращает авторский голос и живой ритм. Это измеримые свойства языка (метрики в [`eval/`](eval/)), которые не зависят от того, насколько плох конкретный детектор. Заодно растут perplexity и burstiness: то, что детекторы (как умеют) и пытаются мерить.
273
+ **Что это значит для очеловечивания текста.** Гнаться за «обходом детектора» на русском значит подгонять текст под ненадёжный и постоянно меняющийся индикатор. Поэтому humanizer-ru оптимизирует не обман детектора, а **реальное качество текста**: убирает канцелярит, кальки и штампы, возвращает авторский голос и живой ритм. Это измеримые свойства языка (метрики в [`eval/`](eval/)), которые не зависят от того, насколько плох конкретный детектор. Заодно растут perplexity и burstiness: то, что детекторы (как умеют) и пытаются измерять.
276
274
 
277
275
  Мы прогоняем детекторы прямо в eval-харнесе по контролю «заведомо человек» (дословные отрывки статей Википедии, написанные людьми задолго до ChatGPT). В последнем прогоне локальный LLM-детектор пометил как ИИ **3 из 3** человеческих текстов (false positive rate 100%), поставив им ту же вероятность «это AI», что и настоящим AI-текстам. Различающая способность близка к нулю. Методика, корпус и сырые прогоны: [eval/RESULTS.md](eval/RESULTS.md), секция «FP-аудит детекторов».
278
276
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanizer-ru",
3
- "version": "3.19.1",
3
+ "version": "3.20.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.19.1
7
+ # Humanizer-RU v3.20.0
8
8
 
9
9
  Ты редактор. Превращаешь стерильный AI-текст в живую русскую речь. Убираешь маркеры нейросети и возвращаешь в текст автора: мнение, ритм, характер.
10
10
 
@@ -77,7 +77,7 @@ license: MIT
77
77
 
78
78
  > **Машинная половина аудита.** Со скиллом едет детерминированный сканер: `scripts/scan.py` рядом с этим SKILL.md (в полном клоне репозитория: `skills/humanizer-ru/scripts/scan.py`). Он точно считает то, что LLM мерит на глаз: HARD BANS, 21 категорию маркеров, ритм предложений, морфологию. Если в среде можно запускать команды и есть python3 с пакетами `razdel` и `pymorphy3`, сначала прогони текст через него: `python3 <папка скилла>/scripts/scan.py файл.txt`, либо `echo "текст" | python3 <папка скилла>/scripts/scan.py -` (флаг `--json` для машинного вывода). Текст не маркетинговый? Добавь `--genre academic`, `--genre legal` или `--genre fiction`: флаг снимает маркеры, законные для регистра, иначе научный и юридический текст тонет в ложных срабатываниях (цифры в секции «Ложные срабатывания»). Вывод сканера бери как базу, поверх добавляй то, что скрипту не по зубам: ложные срабатывания (см. одноимённую секцию), нелексические паттерны, приоритеты A-D. Пакетов нет? Предложи пользователю однократный `pip install razdel pymorphy3` и проводи аудит по каталогу вручную. Сканер недоступен или упал: молча работай по каталогу, как обычно. Аудит обязан состояться при любом раскладе.
79
79
 
80
- > **Score чистоты (0-100).** Сканер выдаёт сводную оценку `ЧИСТОТА: N/100` и полосу: 85-100 «чисто» (следы ИИ не мешают), 60-84 «точечная правка», ниже 60 «рерайт». Это не вероятность ИИ и не «детект», а агрегат уже посчитанных сигналов: хард-баны и плотность маркеров весят сильно, морфология (сущ./глаг.) и тире слабо, как корроборирующие, чтобы не занижать плотный энциклопедический или юридический текст. Плюс document-level сигналы (ровные по длине абзацы, засилье однотипных пунктов-списков) для длинных шаблонных текстов: на короткой прозе они инертны. Показывай число в начале аудита, а после переписывания давай «было N, стало M»: так правка становится измеримой. Сканер не запускался (нет python, claude.ai web)? Прикинь score по той же логике: старт 100, заметный минус за каждый хард-бан и за высокую плотность маркеров, небольшой за номинальность и обилие тире; плотный человеческий текст без AI-оборотов остаётся высоким, не занижай его.
80
+ > **Score чистоты (0-100).** Сканер выдаёт сводную оценку `ЧИСТОТА: N/100` и полосу: 85-100 «чисто» (следы ИИ не мешают), 60-84 «точечная правка», ниже 60 «рерайт». Это не вероятность ИИ и не «детект», а агрегат уже посчитанных сигналов: хард-баны и плотность маркеров весят сильно, морфология (сущ./глаг.) и тире слабо, как корроборирующие, чтобы не занижать плотный энциклопедический или юридический текст. Плюс document-level сигналы (ровные по длине абзацы, засилье однотипных пунктов-списков) для длинных шаблонных текстов: на короткой прозе они инертны. Показывай число в начале аудита, а после переписывания давай «было N, стало M»: так правка становится измеримой. Сканер не запускался (нет python, claude.ai web)? Прикинь score по той же логике: старт 100, заметный минус за каждый хард-бан и за высокую плотность маркеров, небольшой за номинальность и обилие тире; плотный человеческий текст без AI-оборотов остаётся высоким, не занижай его. Такую прикидку подписывай явно: «≈70, оценка без сканера». Число от сканера и число на глаз в одном формате путают читателя, а измеримость правки наше главное отличие.
81
81
 
82
82
  **Точечная правка** (по запросу: «исправь только X», «убери канцелярит»). Работаешь только с указанной категорией паттернов. Остальное не трогаешь.
83
83
 
@@ -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, не заменяет: сначала стилевой слой, потом дискурсный. Граница применимости: статья мерила беллетристику ~5000 слов; перенос на посты, кейсы и письма - экстраполяция механизма, статьёй не проверенная. Шесть паттернов ниже - выборка дискурсных признаков, пригодных для правки руками; ранжирования «самых сильных» статья не даёт, и весь дискурс (257 фич) они не покрывают. Паттерны 61 и 64 - предложения автору, не автоправки.
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. Эмоция только через тело.** Контринтуитивный маркер: телесной метафорой злоупотребляет ИИ, не человек. Меряется это одной фичей с четырьмя взаимоисключающими вариантами («доминирующий способ подачи эмоции»), то есть речь о том, какой способ в истории ГЛАВНЫЙ, а не о доле эмоций. Телесная подача доминирует в 81% историй ИИ и в 38% человеческих: «сердце сжалось», «ком в горле», «холодок по спине». Прямое называние чувства доминирует, наоборот, чаще у людей: 29% против 8% у ИИ. Писать «почти каждая эмоция подана через тело» из этих цифр нельзя, они про другое. Люди спокойно пишут «мне было страшно» и «до сих пор стыдно»; модель прячет эмоцию в тело; похоже на переусердствование с правилом «show, don't tell», но причин статья не разбирает. Сигнал: по всему тексту чувства поданы исключительно телом и ни разу не названы. Это предложение автору, не автоправка: что именно чувствовал герой, знает автор. Предложи назвать одну-две эмоции прямо, решение за ним.
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,8 +11,7 @@
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import re
15
- from dataclasses import dataclass
14
+ from dataclasses import dataclass, replace
16
15
 
17
16
  from .burstiness import RhythmStats, rhythm, rhythm_verdict
18
17
  from .markers import (
@@ -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,57 +74,26 @@ 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
- _FENCED = re.compile(r"(?s)```.*?```")
88
- _INLINE_CODE = re.compile(r"`[^`\n]*`")
89
- _QUOTE = re.compile(r"«[^«»]*»")
77
+ # Разбор границ Markdown живёт в markdown.py (один построчный проход по
78
+ # CommonMark и таблица тестов), здесь только две проекции текста.
79
+ from .markdown import GAP, QUOTE_MAX_WORDS, mask_foreign, strip_foreign # noqa: E402
90
80
 
91
-
92
- def _blank(match: re.Match[str]) -> str:
93
- return re.sub(r"[^\n]", GAP, match.group(0))
94
-
95
-
96
- def _blank_short_quote(match: re.Match[str]) -> str:
97
- inner = match.group(0)[1:-1]
98
- if len(inner.split()) <= QUOTE_MAX_WORDS:
99
- return _blank(match)
100
- return match.group(0)
101
-
102
-
103
- def mask_code_and_quotes(text: str) -> str:
104
- """Текст для лексического сканера: код и короткие цитаты заглушены,
105
- смещения и номера строк сохранены."""
106
- text = _FENCED.sub(_blank, text)
107
- text = _INLINE_CODE.sub(_blank, text)
108
- return _QUOTE.sub(_blank_short_quote, text)
109
-
110
-
111
- def strip_code(text: str) -> str:
112
- """Текст для ритма и морфологии: код удалён, проза оставлена как есть."""
113
- text = _FENCED.sub("", text)
114
- return _INLINE_CODE.sub("", text)
81
+ mask_code_and_quotes = mask_foreign
82
+ strip_code = strip_foreign
115
83
 
116
84
 
117
85
  def analyze(text: str) -> Report:
118
86
  """Полный детерминированный прогон текста."""
119
87
  lexical = mask_code_and_quotes(text)
120
88
  prose = strip_code(text)
89
+ stats = rhythm(prose)
90
+ # Тире внутри короткой цитаты («Война — и мир») не типографика автора:
91
+ # считаем его по заглушенному тексту, ритм по прозе с цитатами.
92
+ stats = replace(stats, em_dash=lexical.count("—"))
121
93
  return Report(
122
94
  hard_bans=scan_hard_bans(lexical),
123
95
  markers=scan_markers(lexical),
124
- rhythm=rhythm(prose),
96
+ rhythm=stats,
125
97
  morph=morph_stats(prose),
126
98
  structure=structure_stats(prose),
127
99
  )
@@ -1,4 +1,4 @@
1
- """Ритм и типографика: то, что скилл велит мерить дисперсией, а не на глаз.
1
+ """Ритм и типографика: то, что скилл велит измерять дисперсией, а не на глаз.
2
2
 
3
3
  Закрывает пункты чеклиста «вариативность длины предложений — не средняя, а
4
4
  ДИСПЕРСИЯ» и «ноль длинных тире». Burstiness тут — статистический прокси, не сам
@@ -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