humanizer-ru 3.19.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.
@@ -0,0 +1,182 @@
1
+ """Score чистоты: сворачивает детерминированные сигналы в одно число 0-100.
2
+
3
+ Выше = текст читается живее/человечнее. Это НЕ детектор и НЕ вероятность ИИ:
4
+ просто агрегат уже считаемых метрик (хард-баны, маркеры, ритм, морфология),
5
+ поданный как обратная связь «было/стало». Опирается на пороги, которые уже
6
+ откалиброваны в burstiness/morphology и задокументированы в eval/RESULTS.md.
7
+
8
+ Пороги штрафов подобраны на eval/corpus (human → высокий score, raw-AI →
9
+ низкий, humanized → между). Калибровочный прогон: см. eval/run_eval.py.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .burstiness import CV_HUMAN_TARGET
17
+ from .markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES,
18
+ effective_hard_bans, mute_by_genre)
19
+ from .morphology import NV_TARGET
20
+ from .structure import (
21
+ LISTICLE_MIN_ITEMS,
22
+ LISTICLE_SHARE_AI,
23
+ PARA_CV_AI,
24
+ PARA_MIN_COUNT,
25
+ )
26
+
27
+ # Тире — отдельный случай: оно и хард-бан, и штатная русская пунктуация
28
+ # (Википедия, диапазоны, «это —»). Поэтому в score не рубим потолком, а
29
+ # штрафуем по плотности с допуском. Имя берём из HARD_BANS markers.py.
30
+ EM_DASH_NAME = "Длинное тире"
31
+ COPY_PASTE_CATEGORY = "Артефакты копипасты"
32
+
33
+ # Полосы. Совпадают с порогами вмешательства из SKILL.md.
34
+ BAND_CLEAN = 85 # ≥ — следы ИИ не мешают, не править
35
+ BAND_EDIT = 60 # ≥ — точечная правка; < — полный рерайт
36
+
37
+ # Доля ЛЮДЕЙ, у которых текст такой длины совсем без банов и без маркеров.
38
+ # Измерено на 14 973 человеческих текстах (Пикабу, M4, AINL), см. eval/.
39
+ #
40
+ # Зачем это здесь. Score мерит расстояние до нуля, а не до человека, и молча
41
+ # внушает, что цель это сто из ста. Человек нулём почти не бывает: на тексте в
42
+ # три сотни слов идеально чистых людей 15%, а не 100%. То есть высшая оценка
43
+ # означает попадание в редкий хвост распределения. Один такой текст неотличим;
44
+ # корпус из тысячи таких текстов отличим тривиально, потому что у людей хвост
45
+ # есть, а у вычищенного корпуса его нет.
46
+ #
47
+ # Штрафовать за чистоту мы не будем: это сломало бы сравнение «было и стало».
48
+ # Вместо этого при нулевом счёте отдаём заметку, чтобы цель читалась как
49
+ # «попади в типичную частоту», а не как «вычисти всё».
50
+ HUMAN_ZERO_SHARE: tuple[tuple[int, float], ...] = ((100, 42.2), (200, 21.0), (400, 15.1))
51
+ STERILE_MIN_WORDS = 100
52
+
53
+
54
+ @dataclass
55
+ class ScoreResult:
56
+ score: int # 0-100, выше = чище
57
+ band: str # "чисто" | "правка" | "рерайт"
58
+ penalties: list[tuple[str, int]] # (причина, -очки) — это и есть verbose-отчёт
59
+ notes: list[str] = field(default_factory=list) # замечания без штрафа
60
+
61
+ def as_dict(self) -> dict:
62
+ return {
63
+ "score": self.score,
64
+ "band": self.band,
65
+ "penalties": [{"reason": r, "points": p} for r, p in self.penalties],
66
+ "notes": list(self.notes),
67
+ }
68
+
69
+
70
+ def _band(score: float) -> str:
71
+ if score >= BAND_CLEAN:
72
+ return "чисто"
73
+ if score >= BAND_EDIT:
74
+ return "правка"
75
+ return "рерайт"
76
+
77
+
78
+ def _per100(count: int, words: int) -> float:
79
+ return (count / words * 100) if words else 0.0
80
+
81
+
82
+ def cleanliness_score(report, genre: str | None = None) -> ScoreResult:
83
+ """Считает score 0-100 из готового Report (см. humanizer_metrics.analyze).
84
+
85
+ genre снимает штрафы за маркеры, законные для регистра (научный,
86
+ юридический, художественный). Без genre режим строгий, как раньше.
87
+ """
88
+ words = report.rhythm.words or 1
89
+ markers = mute_by_genre(report.markers, genre, GENRE_MUTED_CATEGORIES)
90
+ dash_muted = EM_DASH_NAME in GENRE_MUTED_BANS.get(genre or "", set())
91
+ penalties: list[tuple[str, int]] = []
92
+ notes: list[str] = []
93
+ score = 100.0
94
+
95
+ # 1. Фразовые хард-баны (кроме тире). Однозначные AI-обороты: дорого.
96
+ # Частотные баны («Является») штрафуются только выше порога плотности.
97
+ eff_bans = mute_by_genre(
98
+ effective_hard_bans(report.hard_bans, report.rhythm.words),
99
+ genre, GENRE_MUTED_BANS)
100
+ hard_phrase = sum(h.count for h in eff_bans if h.marker != EM_DASH_NAME)
101
+ if hard_phrase:
102
+ pen = min(45, 12 * hard_phrase)
103
+ score -= pen
104
+ penalties.append((f"хард-баны (фразы): {hard_phrase}", -pen))
105
+
106
+ # 2. Артефакты копипасты из чат-бота: текст буквально вставлен из ответа ИИ.
107
+ copy_paste = sum(h.count for h in markers if h.category == COPY_PASTE_CATEGORY)
108
+ if copy_paste:
109
+ pen = 60
110
+ score -= pen
111
+ penalties.append((f"артефакты копипасты: {copy_paste}", -pen))
112
+
113
+ # 3. Мягкие маркеры (кроме копипасты) по плотности на 100 слов.
114
+ soft = sum(h.count for h in markers if h.category != COPY_PASTE_CATEGORY)
115
+ if soft:
116
+ pen = min(30, round(2 * _per100(soft, words)))
117
+ if pen:
118
+ score -= pen
119
+ penalties.append((f"маркеры: {soft} ({_per100(soft, words):.1f}/100 слов)", -pen))
120
+
121
+ # 4. Длинное тире по плотности с допуском ~2 на 100 слов: «—» штатно
122
+ # используется в русском (Википедия, «это —», диапазоны). Штраф мягкий,
123
+ # потому что на изданной прозе оно частая норма; сам бан держится на
124
+ # парном замере (markers.py, комментарий про парный замер).
125
+ dash_density = 0.0 if dash_muted else _per100(report.rhythm.em_dash, words)
126
+ if dash_density > 2.0:
127
+ pen = min(8, round(3 * (dash_density - 2.0)))
128
+ if pen:
129
+ score -= pen
130
+ penalties.append((f"тире: {report.rhythm.em_dash} ({dash_density:.1f}/100 слов)", -pen))
131
+
132
+ # 5. Ровный ритм: чем ниже CV относительно цели 0.45, тем больше штраф.
133
+ cv = report.rhythm.cv_len
134
+ if report.rhythm.sentences >= 4 and cv < CV_HUMAN_TARGET:
135
+ pen = min(20, round((CV_HUMAN_TARGET - cv) / CV_HUMAN_TARGET * 30))
136
+ if pen:
137
+ score -= pen
138
+ penalties.append((f"ровный ритм (CV={cv}, цель ≥{CV_HUMAN_TARGET})", -pen))
139
+
140
+ # 6. Номинальность: сущ./глаг. выше цели 2.5 = канцелярит. Слабый сигнал и
141
+ # главный источник ложных срабатываний (энциклопедический/юр. регистр
142
+ # легитимно номинален), поэтому штраф мягкий и низко ограничен.
143
+ nv = report.morph.noun_verb_ratio
144
+ if nv > NV_TARGET:
145
+ pen = min(8, round((nv - NV_TARGET) / 0.5 * 3))
146
+ if pen:
147
+ score -= pen
148
+ penalties.append((f"номинальность (сущ./глаг.={nv}, цель ≤{NV_TARGET})", -pen))
149
+
150
+ # 7. Document-level: ровные по длине абзацы (burstiness абзацев). Срабатывает
151
+ # только на достаточно многоабзацном тексте, иначе инертно (короткая проза).
152
+ st = report.structure
153
+ if st.paragraphs >= PARA_MIN_COUNT and st.para_cv < PARA_CV_AI:
154
+ pen = min(10, round((PARA_CV_AI - st.para_cv) / PARA_CV_AI * 20))
155
+ if pen:
156
+ score -= pen
157
+ penalties.append((f"ровные абзацы (CV={st.para_cv}, цель ≥{PARA_CV_AI})", -pen))
158
+
159
+ # 8. Document-level: listicle-сигнатура (засилье однотипных пунктов). Инертно
160
+ # на прозе без списков, бьёт по шаблонным гайдам/постам.
161
+ if st.list_items >= LISTICLE_MIN_ITEMS and st.listicle_share > LISTICLE_SHARE_AI:
162
+ pen = min(12, round((st.listicle_share - LISTICLE_SHARE_AI) * 30))
163
+ if pen:
164
+ score -= pen
165
+ penalties.append((f"листикл ({st.list_items} пунктов, {int(st.listicle_share*100)}% строк)", -pen))
166
+
167
+ # Заметка о стерильности. Не штраф: цель показать, что ноль это не середина
168
+ # человеческого распределения, а его редкий край.
169
+ #
170
+ # Условие ровно то, что измерялось: ноль банов и ноль маркеров. Ритм,
171
+ # номинальность и структура сюда не входят, иначе текст с минусом за ровный
172
+ # ритм терял бы заметку, хотя по лексике он как раз стерилен.
173
+ if not (hard_phrase or copy_paste or soft) and words >= STERILE_MIN_WORDS:
174
+ share = next((s for limit, s in HUMAN_ZERO_SHARE if words < limit),
175
+ HUMAN_ZERO_SHARE[-1][1])
176
+ notes.append(
177
+ f"стерильно: ни одного маркера. Так пишет {share:.0f}% людей на тексте "
178
+ f"в {words} слов, остальные {100 - share:.0f}% что-нибудь да используют. "
179
+ "Цель не ноль, а типичная для жанра частота: вычищать дальше незачем")
180
+
181
+ final = max(0, min(100, round(score)))
182
+ return ScoreResult(score=final, band=_band(final), penalties=penalties, notes=notes)
@@ -0,0 +1,154 @@
1
+ """Структурные метрики уровня документа: burstiness абзацев и listicle-сигнатура.
2
+
3
+ Дополняет burstiness.py (там ритм ПРЕДЛОЖЕНИЙ) тем, что детекторы оценивают на
4
+ УРОВНЕ ДОКУМЕНТА: одинаковая длина абзацев и засилье однотипных списков выдают
5
+ шаблонную генерацию, даже когда каждое предложение по отдельности вычищено.
6
+
7
+ Эти сигналы целят в многосекционный/листикл-текст (посты, гайды). На прозе
8
+ одним блоком они инертны (мало абзацев, нет списков) и в score не вносят штраф,
9
+ поэтому не задевают калибровку основного корпуса.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import statistics
16
+ from dataclasses import dataclass
17
+
18
+ from razdel import sentenize
19
+
20
+ from .morphology import _morph
21
+
22
+ # Строка-пункт списка: маркер «- * •» или «1. / 1)» в начале строки.
23
+ _LIST_RE = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s+\S")
24
+
25
+ # Заголовок: markdown-решётки либо короткая строка без конечной пунктуации,
26
+ # за которой идёт пустая строка или абзац.
27
+ _HEADING_RE = re.compile(r"^\s*(?:#{1,6}\s+)?(\S.{0,79})$")
28
+ _WORD_RE = re.compile(r"[А-Яа-яЁё][А-Яа-яЁё-]*")
29
+ # Имя собственное капитализируется законно: тег pymorphy3 или незнакомое
30
+ # словарю слово (Стэнфорд, Хогвартс). Считаем только заглавные у нарицательных.
31
+ _PROPER_TAGS = {"Name", "Surn", "Geox", "Orgn", "Patr"}
32
+ # Служебные слова в заголовках пишутся строчными и в английском Title Case,
33
+ # поэтому их регистр ничего не говорит.
34
+ _STOPWORDS = {"и", "в", "на", "с", "к", "по", "для", "из", "о", "об", "а", "но", "или", "у", "за"}
35
+
36
+
37
+ @dataclass
38
+ class StructureStats:
39
+ paragraphs: int # число непустых абзацев (разделитель — пустая строка)
40
+ para_mean_sent: float # средняя длина абзаца в предложениях
41
+ para_cv: float # коэффициент вариации длин абзацев (burstiness абзацев)
42
+ list_items: int # строк-пунктов списка
43
+ listicle_share: float # доля строк-пунктов среди непустых строк
44
+ title_case_headings: int # заголовки в английском Title Case (паттерн #56)
45
+ truncated_ending: bool # текст оборван на полуслове (паттерн #57)
46
+
47
+ def as_dict(self) -> dict:
48
+ return self.__dict__.copy()
49
+
50
+
51
+ def _is_common_noun_capitalized(word: str) -> bool:
52
+ """Слово с заглавной, которое словарь знает как нарицательное: «Жизнь»,
53
+ «Образование». Имена собственные и незнакомые словарю слова не в счёт."""
54
+ if not word[:1].isupper():
55
+ return False
56
+ if word.lower() in _STOPWORDS:
57
+ return False
58
+ p = _morph().parse(word.lower())[0]
59
+ if not p.is_known:
60
+ return False
61
+ return not (_PROPER_TAGS & set(p.tag.grammemes))
62
+
63
+
64
+ def count_title_case_headings(text: str) -> int:
65
+ """Заголовки, где с заглавной стоит не только первое слово: «Ранняя Жизнь
66
+ и Образование». В русском так не пишут, это калька с английского."""
67
+ hits = 0
68
+ lines = text.splitlines()
69
+ for i, raw in enumerate(lines):
70
+ s = raw.strip()
71
+ if not s:
72
+ continue
73
+ is_md_heading = s.startswith("#")
74
+ # Строка без конечной пунктуации, короткая, за ней пусто: тоже заголовок.
75
+ looks_like_heading = (
76
+ len(s) <= 80 and s[-1] not in ".!?:;,"
77
+ and (i + 1 >= len(lines) or not lines[i + 1].strip())
78
+ and not _LIST_RE.match(raw)
79
+ )
80
+ if not (is_md_heading or looks_like_heading):
81
+ continue
82
+ words = _WORD_RE.findall(s)
83
+ if len(words) < 3:
84
+ continue # на двух словах отличить заголовок от имени нельзя
85
+ if sum(1 for w in words[1:] if _is_common_noun_capitalized(w)) >= 2:
86
+ hits += 1
87
+ return hits
88
+
89
+
90
+ def is_truncated(text: str) -> bool:
91
+ """Текст оборван на полуслове: модель упёрлась в лимит токенов, а человек
92
+ скопировал как есть. Последняя содержательная строка прозы обязана
93
+ заканчиваться знаком конца предложения."""
94
+ lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()]
95
+ if not lines:
96
+ return False
97
+ last = lines[-1]
98
+ if last.startswith("#") or _LIST_RE.match(last) or last.startswith(("|", ">", "```")):
99
+ return False # заголовок, пункт списка, таблица: пунктуация не нужна
100
+ if len(_WORD_RE.findall(last)) < 4:
101
+ return False # подпись, дата, короткая пометка
102
+ return last[-1] not in ".!?…:»)\"'*_"
103
+
104
+
105
+ def structure_stats(text: str) -> StructureStats:
106
+ paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
107
+ sent_lens = [len(list(sentenize(p))) for p in paras]
108
+ sent_lens = [n for n in sent_lens if n > 0]
109
+ n = len(sent_lens)
110
+
111
+ mean = statistics.mean(sent_lens) if sent_lens else 0.0
112
+ stdev = statistics.pstdev(sent_lens) if n > 1 else 0.0
113
+ cv = (stdev / mean) if mean else 0.0
114
+
115
+ lines = [ln for ln in text.splitlines() if ln.strip()]
116
+ list_items = sum(1 for ln in lines if _LIST_RE.match(ln))
117
+ share = (list_items / len(lines)) if lines else 0.0
118
+
119
+ return StructureStats(
120
+ paragraphs=n,
121
+ para_mean_sent=round(mean, 1),
122
+ para_cv=round(cv, 3),
123
+ list_items=list_items,
124
+ listicle_share=round(share, 3),
125
+ title_case_headings=count_title_case_headings(text),
126
+ truncated_ending=is_truncated(text),
127
+ )
128
+
129
+
130
+ # Пороги (эвристика, согласована с логикой burstiness.py для предложений).
131
+ # Срабатывают только на достаточно длинном/структурированном тексте, чтобы не
132
+ # штрафовать короткую прозу одним-двумя абзацами.
133
+ PARA_CV_AI = 0.35 # ниже = слишком ровные по длине абзацы
134
+ PARA_MIN_COUNT = 6 # по нескольким коротким абзацам cv ненадёжен (малая
135
+ # выборка), судим о ритме абзацев только на длинном тексте
136
+ LISTICLE_SHARE_AI = 0.30 # доля строк-пунктов выше — текст «листиклом»
137
+ LISTICLE_MIN_ITEMS = 6 # и не меньше стольких пунктов (порог шаблонности)
138
+
139
+
140
+ def structure_verdict(s: StructureStats) -> str:
141
+ parts = []
142
+ if s.paragraphs >= PARA_MIN_COUNT and s.para_cv < PARA_CV_AI:
143
+ parts.append(f"⚠ ровные абзацы (CV={s.para_cv}, цель ≥{PARA_CV_AI})")
144
+ else:
145
+ parts.append(f"✓ абзацы: {s.paragraphs}, CV={s.para_cv}")
146
+ if s.list_items >= LISTICLE_MIN_ITEMS and s.listicle_share > LISTICLE_SHARE_AI:
147
+ parts.append(f"⚠ листикл ({s.list_items} пунктов, {int(s.listicle_share*100)}% строк)")
148
+ elif s.list_items:
149
+ parts.append(f"пунктов списка: {s.list_items}")
150
+ if s.title_case_headings:
151
+ parts.append(f"⚠ Title Case в заголовках: {s.title_case_headings} (#56)")
152
+ if s.truncated_ending:
153
+ parts.append("⚠ обрыв на полуслове (#57)")
154
+ return "; ".join(parts)
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """CLI-сканер: прогоняет текст через детерминированные метрики humanizer-ru.
3
+
4
+ Использование (из корня репо; у установленного скилла путь: <папка скилла>/scripts/scan.py):
5
+ python skills/humanizer-ru/scripts/scan.py path/to/text.txt
6
+ echo "ваш текст" | python skills/humanizer-ru/scripts/scan.py -
7
+ python skills/humanizer-ru/scripts/scan.py text.txt --json
8
+
9
+ Это машинная половина режима «Аудит». Для полного очеловечивания (семантика,
10
+ голос, рерайт) нужен сам скилл — этот сканер только подсвечивает грепабельные
11
+ маркеры и считает метрики, которые LLM не умеет мерить на глаз.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ try:
25
+ from humanizer_metrics import analyze, cleanliness_score
26
+ from humanizer_metrics.burstiness import rhythm_verdict
27
+ from humanizer_metrics.markers import (GENRE_MUTED_BANS, GENRE_MUTED_CATEGORIES,
28
+ GENRES, effective_hard_bans, marker_verdict,
29
+ mute_by_genre)
30
+ from humanizer_metrics.morphology import morph_verdict
31
+ from humanizer_metrics.structure import structure_verdict
32
+ except ImportError as exc:
33
+ print(f"[ошибка] не хватает зависимостей сканера ({exc.name}).\n"
34
+ "Поставьте один раз: pip install razdel pymorphy3", file=sys.stderr)
35
+ raise SystemExit(2)
36
+
37
+
38
+ def _read(src: str) -> str:
39
+ if src == "-":
40
+ return sys.stdin.read()
41
+ try:
42
+ return Path(src).read_text(encoding="utf-8")
43
+ except FileNotFoundError:
44
+ print(f"[ошибка] файл не найден: {src}", file=sys.stderr)
45
+ raise SystemExit(2)
46
+ except (IsADirectoryError, UnicodeDecodeError) as exc:
47
+ print(f"[ошибка] не могу прочитать {src}: {exc}", file=sys.stderr)
48
+ raise SystemExit(2)
49
+
50
+
51
+ def _line_numbers(text: str, positions: list[int], limit: int = 3) -> str:
52
+ """Номера строк первых вхождений: «стр. 3, 7» — чтобы находку можно
53
+ было найти в тексте глазами."""
54
+ lines = sorted({text.count("\n", 0, p) + 1 for p in positions})
55
+ shown = ", ".join(str(n) for n in lines[:limit])
56
+ more = f", +{len(lines) - limit}" if len(lines) > limit else ""
57
+ return f"стр. {shown}{more}"
58
+
59
+
60
+ def _cyrillic_share(text: str) -> float:
61
+ letters = re.findall(r"[^\W\d_]", text)
62
+ if not letters:
63
+ return 0.0
64
+ cyr = sum(1 for ch in letters if "а" <= ch.lower() <= "я" or ch.lower() == "ё")
65
+ return cyr / len(letters)
66
+
67
+
68
+ def main() -> int:
69
+ ap = argparse.ArgumentParser(description="Детерминированный сканер AI-маркеров (humanizer-ru)")
70
+ ap.add_argument("source", help="файл с текстом или '-' для stdin")
71
+ ap.add_argument("--json", action="store_true", help="вывод в JSON")
72
+ ap.add_argument("--genre", choices=GENRES, default="marketing",
73
+ help="жанр текста: снимает маркеры, законные для регистра "
74
+ "(academic, legal, fiction). По умолчанию marketing: "
75
+ "строгий режим, под него откалиброваны пороги")
76
+ args = ap.parse_args()
77
+
78
+ text = _read(args.source)
79
+
80
+ if not text.strip():
81
+ if args.json:
82
+ print(json.dumps({"empty": True}, ensure_ascii=False))
83
+ else:
84
+ print("Текст пуст, сканировать нечего.")
85
+ return 0
86
+
87
+ rep = analyze(text)
88
+ genre = args.genre
89
+ sc = cleanliness_score(rep, genre)
90
+ # Частотные баны («Является» до порога 1/500 слов) не валят exit и не
91
+ # показываются как ⛔ — они остаются в мягких маркерах.
92
+ bans = effective_hard_bans(rep.hard_bans, rep.rhythm.words)
93
+ # Жанровый фильтр: в научном, юридическом и художественном регистре часть
94
+ # банов законна. Замер на AINL-Eval показал, что без фильтра сканер ловит
95
+ # людей чаще, чем модели (см. markers.GENRE_MUTED_BANS).
96
+ bans = mute_by_genre(bans, genre, GENRE_MUTED_BANS)
97
+ soft = mute_by_genre(rep.markers, genre, GENRE_MUTED_CATEGORIES)
98
+ muted_bans = len(effective_hard_bans(rep.hard_bans, rep.rhythm.words)) - len(bans)
99
+ muted_soft = len(rep.markers) - len(soft)
100
+ EM_DASH_MUTED = "Длинное тире" in GENRE_MUTED_BANS.get(genre, set())
101
+
102
+ if args.json:
103
+ out = rep.as_dict()
104
+ out["score"] = sc.as_dict()
105
+ # Ключи те же, что без жанра: меняется только состав после фильтра.
106
+ out["genre"] = genre
107
+ out["hard_bans"] = [(h.marker, h.count) for h in bans]
108
+ out["hard_ban_count"] = sum(h.count for h in bans)
109
+ out["markers"] = [(h.category, h.marker, h.count) for h in soft]
110
+ out["marker_count"] = sum(h.count for h in soft)
111
+ out["muted_by_genre"] = {"hard_bans": muted_bans, "markers": muted_soft}
112
+ if _cyrillic_share(text) < 0.3:
113
+ out["warning"] = "текст не похож на русский, метрики не применимы"
114
+ print(json.dumps(out, ensure_ascii=False, indent=2))
115
+ return 1 if bans else 0
116
+
117
+ print(f"=== humanizer-ru scan: {args.source} ===")
118
+ if genre != "marketing":
119
+ print(f"жанр: {genre} (снято маркеров по жанру: "
120
+ f"{muted_bans} банов, {muted_soft} мягких)")
121
+ print()
122
+
123
+ if _cyrillic_share(text) < 0.3:
124
+ print("⚠ Текст не похож на русский: скилл и метрики рассчитаны на русский "
125
+ "язык, отчёт ниже не показателен.\n")
126
+
127
+ print(f"ЧИСТОТА: {sc.score}/100 [{sc.band}]")
128
+ print(" (≥85 чисто · 60-84 точечная правка · <60 рерайт)")
129
+ if sc.penalties:
130
+ for reason, pts in sc.penalties:
131
+ print(f" {pts:+d} {reason}")
132
+ else:
133
+ print(" без штрафов")
134
+ for note in sc.notes:
135
+ print(f" ℹ {note}")
136
+ print()
137
+
138
+ print("HARD BANS:")
139
+ if bans:
140
+ for h in bans:
141
+ note = " (штраф по плотности, допуск ~2/100 слов)" if h.marker == "Длинное тире" else ""
142
+ print(f" ⛔ {h.marker} ×{h.count} ({_line_numbers(text, h.positions)}){note}")
143
+ else:
144
+ print(" ✓ чисто")
145
+ print()
146
+
147
+ print(f"Маркеры (быстрый сканер): {marker_verdict(soft)}")
148
+ top = sorted(soft, key=lambda x: -x.count)
149
+ for h in top[:12]:
150
+ print(f" • [{h.category}] «{h.marker}» ×{h.count} ({_line_numbers(text, h.positions)})")
151
+ if len(top) > 12:
152
+ print(f" … и ещё {len(top) - 12}")
153
+ print()
154
+
155
+ print("Ритм / типографика:")
156
+ print(f" {rhythm_verdict(rep.rhythm, dash_ok=EM_DASH_MUTED)}")
157
+ print(f" предложений: {rep.rhythm.sentences}, средняя длина: {rep.rhythm.mean_len:.1f} "
158
+ f"(min {rep.rhythm.min_len} / max {rep.rhythm.max_len}), CV: {rep.rhythm.cv_len}")
159
+ print(f" многоточий: {rep.rhythm.ellipsis}, скобок: {rep.rhythm.parentheses}, "
160
+ f"вопросов: {rep.rhythm.questions}")
161
+ print()
162
+
163
+ print("Морфология:")
164
+ print(f" {morph_verdict(rep.morph)}")
165
+ print(f" сущ.: {rep.morph.nouns}, глаг.форм: {rep.morph.verbs}, "
166
+ f"номинализаций: {rep.morph.nominalizations}")
167
+ print()
168
+
169
+ print("Структура (уровень документа):")
170
+ print(f" {structure_verdict(rep.structure)}")
171
+
172
+ # Exit code: ненулевой, если есть HARD BANS — удобно для CI/pre-commit.
173
+ return 1 if bans else 0
174
+
175
+
176
+ if __name__ == "__main__":
177
+ raise SystemExit(main())