humanizer-ru 3.20.0 → 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 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.20.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Version](https://img.shields.io/badge/version-3.21.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)
@@ -16,6 +16,17 @@ 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
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.20.0-blueviolet)](https://github.com/ilyautov/humanizer-ru/blob/main/CHANGELOG.md)
8
+ [![Версия](https://img.shields.io/badge/версия-3.21.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)
@@ -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
  ## Зачем это нужно
@@ -228,6 +239,7 @@ dsh сканирует `~/.agents/skills` и `~/.dsh/skills` сам, перез
228
239
 
229
240
  ```bash
230
241
  python skills/humanizer-ru/scripts/scan.py статья.txt --genre academic # ещё legal, fiction
242
+ python skills/humanizer-ru/scripts/scan.py стало.txt --before было.txt # чистота до/после + факт-замок
231
243
  ```
232
244
 
233
245
  Зачем это нужно, показал прогон по внешнему размеченному корпусу AINL-Eval 2025
@@ -235,6 +247,11 @@ python skills/humanizer-ru/scripts/scan.py статья.txt --genre academic #
235
247
  человеческих текстов против 26,1-35,8% машинных, то есть на научном тексте
236
248
  каталог не различал автора вовсе. С жанровой поправкой ложные срабатывания на
237
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).
238
255
 
239
256
  ## До и после
240
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanizer-ru",
3
- "version": "3.20.0",
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.20.0
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
 
@@ -77,6 +77,7 @@ class Report:
77
77
  # Разбор границ Markdown живёт в markdown.py (один построчный проход по
78
78
  # CommonMark и таблица тестов), здесь только две проекции текста.
79
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
80
81
 
81
82
  mask_code_and_quotes = mask_foreign
82
83
  strip_code = strip_foreign
@@ -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} фактов исходника перенесено, новых нет"
@@ -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
- print(f"ЧИСТОТА: {sc.score}/100 [{sc.band}]")
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