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.
- package/LICENSE +21 -0
- package/README.en.md +207 -0
- package/README.md +340 -0
- package/cordis.patch.yml +14 -0
- package/package.json +16 -0
- package/skills/humanizer-ru/SKILL.md +320 -0
- package/skills/humanizer-ru/agents/openai.yaml +5 -0
- package/skills/humanizer-ru/edit-log.md +40 -0
- package/skills/humanizer-ru/references/catalog.md +354 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/__init__.py +80 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/burstiness.py +92 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/markers.py +439 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/morphology.py +78 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/score.py +182 -0
- package/skills/humanizer-ru/scripts/humanizer_metrics/structure.py +154 -0
- package/skills/humanizer-ru/scripts/scan.py +177 -0
|
@@ -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())
|