ru-humanizer 3.26.0__py3-none-any.whl
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.
- humanizer_metrics/__init__.py +100 -0
- humanizer_metrics/burstiness.py +132 -0
- humanizer_metrics/facts.py +193 -0
- humanizer_metrics/markdown.py +127 -0
- humanizer_metrics/markers.py +462 -0
- humanizer_metrics/mcp_server.py +254 -0
- humanizer_metrics/morphology.py +78 -0
- humanizer_metrics/score.py +208 -0
- humanizer_metrics/structure.py +154 -0
- ru_humanizer-3.26.0.dist-info/METADATA +73 -0
- ru_humanizer-3.26.0.dist-info/RECORD +16 -0
- ru_humanizer-3.26.0.dist-info/WHEEL +5 -0
- ru_humanizer-3.26.0.dist-info/entry_points.txt +3 -0
- ru_humanizer-3.26.0.dist-info/licenses/LICENSE +21 -0
- ru_humanizer-3.26.0.dist-info/top_level.txt +2 -0
- ru_humanizer_cli/scan.py +208 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""humanizer_metrics — детерминированные метрики живости текста.
|
|
2
|
+
|
|
3
|
+
Это грепабельная половина режима «Аудит» из SKILL.md: то, что считается
|
|
4
|
+
машиной, а не LLM. Используется и как локальный буст для Claude Code, и как
|
|
5
|
+
движок eval-харнеса (eval/run_eval.py), и как self-test самого скилла
|
|
6
|
+
(scripts/lint_skill.py).
|
|
7
|
+
|
|
8
|
+
Семантику (кальки, ирония, translationese, голос) тут не ловим — для этого
|
|
9
|
+
нужен сам скилл. Скрипты не работают в claude.ai web; скилл от них не зависит.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
|
|
16
|
+
from .burstiness import RhythmStats, rhythm, rhythm_verdict
|
|
17
|
+
from .markers import (
|
|
18
|
+
MarkerHit,
|
|
19
|
+
marker_verdict,
|
|
20
|
+
scan_hard_bans,
|
|
21
|
+
scan_markers,
|
|
22
|
+
)
|
|
23
|
+
from .morphology import MorphStats, morph_stats, morph_verdict
|
|
24
|
+
from .structure import StructureStats, structure_stats, structure_verdict
|
|
25
|
+
from .score import ScoreResult, cleanliness_score
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Report",
|
|
29
|
+
"analyze",
|
|
30
|
+
"RhythmStats",
|
|
31
|
+
"MorphStats",
|
|
32
|
+
"StructureStats",
|
|
33
|
+
"MarkerHit",
|
|
34
|
+
"ScoreResult",
|
|
35
|
+
"cleanliness_score",
|
|
36
|
+
"rhythm",
|
|
37
|
+
"morph_stats",
|
|
38
|
+
"structure_stats",
|
|
39
|
+
"scan_hard_bans",
|
|
40
|
+
"scan_markers",
|
|
41
|
+
"mask_foreign",
|
|
42
|
+
"strip_foreign",
|
|
43
|
+
"GAP",
|
|
44
|
+
"QUOTE_MAX_WORDS",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Report:
|
|
50
|
+
hard_bans: list[MarkerHit]
|
|
51
|
+
markers: list[MarkerHit]
|
|
52
|
+
rhythm: RhythmStats
|
|
53
|
+
morph: MorphStats
|
|
54
|
+
structure: StructureStats
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def hard_ban_count(self) -> int:
|
|
58
|
+
return sum(h.count for h in self.hard_bans)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def marker_count(self) -> int:
|
|
62
|
+
return sum(h.count for h in self.markers)
|
|
63
|
+
|
|
64
|
+
def as_dict(self) -> dict:
|
|
65
|
+
return {
|
|
66
|
+
"hard_ban_count": self.hard_ban_count,
|
|
67
|
+
"hard_bans": [(h.marker, h.count) for h in self.hard_bans],
|
|
68
|
+
"marker_count": self.marker_count,
|
|
69
|
+
"markers": [(h.category, h.marker, h.count) for h in self.markers],
|
|
70
|
+
"rhythm": self.rhythm.as_dict(),
|
|
71
|
+
"morph": self.morph.as_dict(),
|
|
72
|
+
"structure": self.structure.as_dict(),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- Код и цитаты не текст автора ------------------------------------------
|
|
77
|
+
# Разбор границ Markdown живёт в markdown.py (один построчный проход по
|
|
78
|
+
# CommonMark и таблица тестов), здесь только две проекции текста.
|
|
79
|
+
from .markdown import GAP, QUOTE_MAX_WORDS, mask_foreign, strip_foreign # noqa: E402
|
|
80
|
+
from .facts import Facts, FactsDiff, diff_facts, extract_facts, facts_verdict # noqa: E402
|
|
81
|
+
|
|
82
|
+
mask_code_and_quotes = mask_foreign
|
|
83
|
+
strip_code = strip_foreign
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def analyze(text: str) -> Report:
|
|
87
|
+
"""Полный детерминированный прогон текста."""
|
|
88
|
+
lexical = mask_code_and_quotes(text)
|
|
89
|
+
prose = strip_code(text)
|
|
90
|
+
stats = rhythm(prose)
|
|
91
|
+
# Тире внутри короткой цитаты («Война — и мир») не типографика автора:
|
|
92
|
+
# считаем его по заглушенному тексту, ритм по прозе с цитатами.
|
|
93
|
+
stats = replace(stats, em_dash=lexical.count("—"))
|
|
94
|
+
return Report(
|
|
95
|
+
hard_bans=scan_hard_bans(lexical),
|
|
96
|
+
markers=scan_markers(lexical),
|
|
97
|
+
rhythm=stats,
|
|
98
|
+
morph=morph_stats(prose),
|
|
99
|
+
structure=structure_stats(prose),
|
|
100
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Ритм и типографика: то, что скилл велит измерять дисперсией, а не на глаз.
|
|
2
|
+
|
|
3
|
+
Закрывает пункты чеклиста «вариативность длины предложений — не средняя, а
|
|
4
|
+
ДИСПЕРСИЯ» и «ноль длинных тире». Burstiness тут — статистический прокси, не сам
|
|
5
|
+
детектор: высокий разброс длины предложений коррелирует с человеческим письмом
|
|
6
|
+
(SKILL.md, раздел «Что ловят детекторы»).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import statistics
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from razdel import sentenize, tokenize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RhythmStats:
|
|
20
|
+
sentences: int
|
|
21
|
+
words: int
|
|
22
|
+
mean_len: float # средняя длина предложения в словах
|
|
23
|
+
stdev_len: float # стандартное отклонение длины
|
|
24
|
+
cv_len: float # коэффициент вариации = stdev/mean (главный показатель)
|
|
25
|
+
min_len: int
|
|
26
|
+
max_len: int
|
|
27
|
+
short_share: float # доля предложений < 5 слов
|
|
28
|
+
long_share: float # доля предложений > 25 слов
|
|
29
|
+
em_dash: int # длинных тире «—»
|
|
30
|
+
ellipsis: int # многоточий
|
|
31
|
+
parentheses: int # скобочных ремарок
|
|
32
|
+
questions: int # вопросительных предложений
|
|
33
|
+
staccato_runs: int = 0 # цепочек из 3+ обрывков подряд (каталог #49)
|
|
34
|
+
staccato_max: int = 0 # длина самой длинной такой цепочки
|
|
35
|
+
|
|
36
|
+
def as_dict(self) -> dict:
|
|
37
|
+
return self.__dict__.copy()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_WORD_RE = re.compile(r"[А-Яа-яЁёA-Za-z0-9]")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _word_count(sentence: str) -> int:
|
|
44
|
+
return sum(1 for t in tokenize(sentence) if _WORD_RE.search(t.text))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Рваная медитативность (каталог #49): «Короткие. Точные. Отдельные.» Цепочка
|
|
48
|
+
# из трёх и более утверждений по 1-3 слова подряд. Не считаются реплики диалога
|
|
49
|
+
# (строка с тире или кавычки в начале), восклицания, вопросы, подводки с
|
|
50
|
+
# двоеточием, пункты списков и заголовки: там обрывки законны. Порог выбран по
|
|
51
|
+
# 400 постам Пикабу: доля обрывков в тексте срабатывала на 5-12% живых текстов
|
|
52
|
+
# и отвергнута, цепочка из трёх подряд даёт 4 из 400 (eval/OVERCORRECTION_CHECK.md).
|
|
53
|
+
STACCATO_MAX_WORDS = 3
|
|
54
|
+
STACCATO_MIN_RUN = 3
|
|
55
|
+
_DIALOG_RE = re.compile(r"^\s*[-–—«\"']")
|
|
56
|
+
_SKIP_LINE_RE = re.compile(r"^\s*(?:(?:[-*•]|\d+[.)])\s+|#)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def staccato_runs(text: str) -> tuple[int, int]:
|
|
60
|
+
"""(число цепочек, длина самой длинной). Цепочка не переходит через строку."""
|
|
61
|
+
runs: list[int] = []
|
|
62
|
+
for line in text.replace("\\", "").split("\n"):
|
|
63
|
+
if not line.strip() or _SKIP_LINE_RE.match(line):
|
|
64
|
+
continue
|
|
65
|
+
cur = 0
|
|
66
|
+
for s in sentenize(line):
|
|
67
|
+
st = s.text.strip()
|
|
68
|
+
n = _word_count(st)
|
|
69
|
+
if not n:
|
|
70
|
+
continue
|
|
71
|
+
if n <= STACCATO_MAX_WORDS and not _DIALOG_RE.match(st) and st[-1] not in "?!:;":
|
|
72
|
+
cur += 1
|
|
73
|
+
else:
|
|
74
|
+
if cur >= STACCATO_MIN_RUN:
|
|
75
|
+
runs.append(cur)
|
|
76
|
+
cur = 0
|
|
77
|
+
if cur >= STACCATO_MIN_RUN:
|
|
78
|
+
runs.append(cur)
|
|
79
|
+
return len(runs), max(runs, default=0)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rhythm(text: str) -> RhythmStats:
|
|
83
|
+
sents = [s.text for s in sentenize(text)]
|
|
84
|
+
lengths = [_word_count(s) for s in sents]
|
|
85
|
+
lengths = [n for n in lengths if n > 0]
|
|
86
|
+
n = len(lengths)
|
|
87
|
+
total_words = sum(lengths)
|
|
88
|
+
|
|
89
|
+
runs, longest = staccato_runs(text)
|
|
90
|
+
mean = statistics.mean(lengths) if lengths else 0.0
|
|
91
|
+
stdev = statistics.pstdev(lengths) if n > 1 else 0.0
|
|
92
|
+
cv = (stdev / mean) if mean else 0.0
|
|
93
|
+
|
|
94
|
+
return RhythmStats(
|
|
95
|
+
sentences=n,
|
|
96
|
+
words=total_words,
|
|
97
|
+
mean_len=round(mean, 1),
|
|
98
|
+
stdev_len=round(stdev, 1),
|
|
99
|
+
cv_len=round(cv, 3),
|
|
100
|
+
min_len=min(lengths) if lengths else 0,
|
|
101
|
+
max_len=max(lengths) if lengths else 0,
|
|
102
|
+
short_share=round(sum(1 for x in lengths if x < 5) / n, 3) if n else 0.0,
|
|
103
|
+
long_share=round(sum(1 for x in lengths if x > 25) / n, 3) if n else 0.0,
|
|
104
|
+
em_dash=text.count("—"),
|
|
105
|
+
ellipsis=text.count("…") + len(re.findall(r"\.\.\.", text)),
|
|
106
|
+
parentheses=text.count("("),
|
|
107
|
+
questions=sum(1 for s in sents if s.rstrip().endswith("?")),
|
|
108
|
+
staccato_runs=runs,
|
|
109
|
+
staccato_max=longest,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Эвристические пороги (откалиброваны под русский грубо, см. eval/RESULTS.md).
|
|
114
|
+
# cv_len < 0.35 — слишком ровный ритм, признак AI. Человеческий текст обычно > 0.45.
|
|
115
|
+
CV_AI_THRESHOLD = 0.35
|
|
116
|
+
CV_HUMAN_TARGET = 0.45
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def rhythm_verdict(s: RhythmStats, dash_ok: bool = False) -> str:
|
|
120
|
+
"""dash_ok=True для жанров, где длинное тире законно (научный,
|
|
121
|
+
юридический, художественный): тогда оно показывается фактом, не ⚠."""
|
|
122
|
+
if s.em_dash > 0 and dash_ok:
|
|
123
|
+
dash = f"тире: {s.em_dash} (законно для жанра)"
|
|
124
|
+
elif s.em_dash > 0:
|
|
125
|
+
dash = f"⚠ {s.em_dash} длинных тире (норма 0)"
|
|
126
|
+
else:
|
|
127
|
+
dash = "✓ тире чисто"
|
|
128
|
+
if s.cv_len < CV_AI_THRESHOLD:
|
|
129
|
+
rhythm_v = f"⚠ ровный ритм (CV={s.cv_len}, цель ≥{CV_HUMAN_TARGET})"
|
|
130
|
+
else:
|
|
131
|
+
rhythm_v = f"✓ ритм рваный (CV={s.cv_len})"
|
|
132
|
+
return f"{rhythm_v}; {dash}"
|
|
@@ -0,0 +1,193 @@
|
|
|
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
|
+
«первый в России», «единственный», «впервые», «рекордный», «до сих пор».
|
|
24
|
+
Срезать «первый в России сервис» до «сервис» значит исказить исходник, а
|
|
25
|
+
не оживить его. Потерянный квантор попадает в список потерь, появившийся
|
|
26
|
+
выносится отдельным предупреждением: кванторы идиоматичны («первый шаг»,
|
|
27
|
+
«самое время»), поэтому валить проверку автоматически нельзя, но глазами
|
|
28
|
+
такое место обязано проверить.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import pymorphy3
|
|
38
|
+
|
|
39
|
+
_MORPH = pymorphy3.MorphAnalyzer()
|
|
40
|
+
except ImportError: # без словаря сравниваем по основе, грубее, но работает
|
|
41
|
+
_MORPH = None
|
|
42
|
+
|
|
43
|
+
PROPER_TAGS = {"Name", "Surn", "Geox", "Orgn", "Patr"}
|
|
44
|
+
|
|
45
|
+
MONTH_RE = re.compile(
|
|
46
|
+
r"\b(январ|феврал|март|апрел|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-я]*\b",
|
|
47
|
+
re.IGNORECASE,
|
|
48
|
+
)
|
|
49
|
+
URL_RE = re.compile(r"https?://[^\s)>\]»]+|\b[a-z0-9-]+\.(?:ru|com|org|net|io|tech|dev|ai|su|рф)(?:/[^\s)>\]»]*)?",
|
|
50
|
+
re.IGNORECASE)
|
|
51
|
+
CODE_RE = re.compile(r"`([^`\n]+)`")
|
|
52
|
+
TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\d\-]*|[А-Яа-яЁё][А-Яа-яЁё\-]*|\d[\d.,:/-]*")
|
|
53
|
+
|
|
54
|
+
SOFT_QUANTITIES = {
|
|
55
|
+
"один", "два", "две", "три", "оба", "обе", "пара",
|
|
56
|
+
"второй", "третий",
|
|
57
|
+
"вдвое", "втрое", "дважды", "трижды",
|
|
58
|
+
"половина", "треть", "четверть", "полтора",
|
|
59
|
+
}
|
|
60
|
+
HARD_NUMERALS = {
|
|
61
|
+
"четыре", "пять", "шесть", "семь", "восемь", "девять", "десять",
|
|
62
|
+
"одиннадцать", "двенадцать", "пятнадцать", "двадцать", "тридцать",
|
|
63
|
+
"сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят",
|
|
64
|
+
"девяносто", "сто", "двести", "триста", "тысяча", "миллион", "миллиард",
|
|
65
|
+
"десяток", "сотня", "дюжина",
|
|
66
|
+
}
|
|
67
|
+
# Кванторы-утверждения: громкие слова, за которыми стоит проверяемый факт.
|
|
68
|
+
CLAIM_WORDS = {
|
|
69
|
+
"первый", "единственный", "впервые", "самый", "рекордный", "крупнейший",
|
|
70
|
+
"старейший", "никогда", "никто", "навсегда", "беспрецедентный",
|
|
71
|
+
}
|
|
72
|
+
CLAIM_PHRASE_RE = re.compile(r"\bдо сих пор\b|\bв мире\b|\bв россии\b", re.IGNORECASE)
|
|
73
|
+
# Одна сущность под разными именами не считается новым фактом.
|
|
74
|
+
ALIASES = {
|
|
75
|
+
"ai": ("искусственный", "интеллект", "ии", "нейросеть", "модель"),
|
|
76
|
+
"ии": ("искусственный", "интеллект", "ai", "нейросеть", "модель"),
|
|
77
|
+
"llm": ("модель", "нейросеть", "ai", "ии"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Facts:
|
|
83
|
+
hard: set[str] = field(default_factory=set) # "число:13", "имя:стэнфорд", "ссылка:…"
|
|
84
|
+
soft: set[str] = field(default_factory=set) # "два", "половина"
|
|
85
|
+
words: set[str] = field(default_factory=set) # все слова в нормальной форме, для алиасов
|
|
86
|
+
claims: set[str] = field(default_factory=set) # "утверждение:первый", "утверждение:до сих пор"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class FactsDiff:
|
|
91
|
+
lost: list[str] # были в исходнике, пропали
|
|
92
|
+
added: list[str] # появились в результате, в исходнике не было
|
|
93
|
+
kept: int # жёстких фактов исходника дожило
|
|
94
|
+
soft_added: list[str]
|
|
95
|
+
claims_added: list[str] = field(default_factory=list) # кванторы, которых в исходнике не было
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def ok(self) -> bool:
|
|
99
|
+
return not self.added
|
|
100
|
+
|
|
101
|
+
def as_dict(self) -> dict:
|
|
102
|
+
return {"ok": self.ok, "lost": self.lost, "added": self.added,
|
|
103
|
+
"kept": self.kept, "soft_added": self.soft_added,
|
|
104
|
+
"claims_added": self.claims_added}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _norm(word: str) -> str:
|
|
108
|
+
low = word.lower().replace("ё", "е").strip("-")
|
|
109
|
+
if _MORPH is None:
|
|
110
|
+
return low[:5]
|
|
111
|
+
return _MORPH.parse(low)[0].normal_form.replace("ё", "е")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _sentence_starts(text: str) -> set[int]:
|
|
115
|
+
starts = {0}
|
|
116
|
+
for m in re.finditer(r"[.!?…]\s+|^[>\-*]\s+|«|\n", text):
|
|
117
|
+
starts.add(m.end())
|
|
118
|
+
return starts
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_proper(word: str, at_start: bool) -> bool:
|
|
122
|
+
if not word[:1].isupper():
|
|
123
|
+
return False
|
|
124
|
+
if word.isascii():
|
|
125
|
+
return True
|
|
126
|
+
if _MORPH is None:
|
|
127
|
+
return not at_start
|
|
128
|
+
p = _MORPH.parse(word.lower())[0]
|
|
129
|
+
if PROPER_TAGS & set(p.tag.grammemes):
|
|
130
|
+
return True
|
|
131
|
+
if not p.is_known:
|
|
132
|
+
return True
|
|
133
|
+
return not at_start
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def extract_facts(text: str) -> Facts:
|
|
137
|
+
f = Facts()
|
|
138
|
+
for m in URL_RE.finditer(text):
|
|
139
|
+
url = re.sub(r"^https?://(?:www\.)?", "", m.group(0).rstrip(".,;:").lower()).rstrip("/")
|
|
140
|
+
f.hard.add("ссылка:" + url)
|
|
141
|
+
for m in CODE_RE.finditer(text):
|
|
142
|
+
f.hard.add("код:" + m.group(1).strip())
|
|
143
|
+
body = CODE_RE.sub(" ", URL_RE.sub(" ", text))
|
|
144
|
+
for m in MONTH_RE.finditer(body):
|
|
145
|
+
f.hard.add("месяц:" + m.group(1).lower())
|
|
146
|
+
for m in CLAIM_PHRASE_RE.finditer(body):
|
|
147
|
+
f.claims.add("утверждение:" + m.group(0).lower().replace("ё", "е"))
|
|
148
|
+
starts = _sentence_starts(body)
|
|
149
|
+
for m in TOKEN_RE.finditer(body):
|
|
150
|
+
tok = m.group(0)
|
|
151
|
+
if tok[0].isdigit():
|
|
152
|
+
digits = re.sub(r"\D", "", tok)
|
|
153
|
+
if digits:
|
|
154
|
+
f.hard.add("число:" + (digits.lstrip("0") or "0"))
|
|
155
|
+
continue
|
|
156
|
+
norm = _norm(tok)
|
|
157
|
+
f.words.add(norm)
|
|
158
|
+
if norm in SOFT_QUANTITIES:
|
|
159
|
+
f.soft.add(norm)
|
|
160
|
+
elif norm in CLAIM_WORDS:
|
|
161
|
+
f.claims.add("утверждение:" + norm)
|
|
162
|
+
elif norm in HARD_NUMERALS:
|
|
163
|
+
f.hard.add("число словами:" + norm)
|
|
164
|
+
elif _is_proper(tok, any(abs(m.start() - s) <= 1 for s in starts)):
|
|
165
|
+
f.hard.add("имя:" + norm)
|
|
166
|
+
return f
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _alias_covered(fact: str, before_words: set[str]) -> bool:
|
|
170
|
+
if not fact.startswith("имя:"):
|
|
171
|
+
return False
|
|
172
|
+
name = fact[4:]
|
|
173
|
+
return any(alias in before_words for alias in ALIASES.get(name, ()))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def diff_facts(before: str, after: str) -> FactsDiff:
|
|
177
|
+
b, a = extract_facts(before), extract_facts(after)
|
|
178
|
+
lost = sorted(b.hard - a.hard) + sorted(b.claims - a.claims)
|
|
179
|
+
added = sorted(x for x in a.hard - b.hard if not _alias_covered(x, b.words))
|
|
180
|
+
return FactsDiff(lost=lost, added=added, kept=len(b.hard & a.hard),
|
|
181
|
+
soft_added=sorted(a.soft - b.soft),
|
|
182
|
+
claims_added=sorted(a.claims - b.claims))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def facts_verdict(d: FactsDiff) -> str:
|
|
186
|
+
if d.added:
|
|
187
|
+
return f"✗ новых фактов без источника: {len(d.added)} (выдумка хуже канцелярита)"
|
|
188
|
+
if d.lost:
|
|
189
|
+
return f"⚠ факты исходника на месте, но {len(d.lost)} потеряно: проверьте, намеренно ли"
|
|
190
|
+
if d.claims_added:
|
|
191
|
+
return (f"⚠ факты целы, но появилось утверждений без источника: {len(d.claims_added)} "
|
|
192
|
+
"(«впервые», «единственный» это факт, а не украшение)")
|
|
193
|
+
return f"✓ факт-замок цел: {d.kept} фактов исходника перенесено, новых нет"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Границы чужого текста в Markdown: код, цитаты, короткие «ёлочки».
|
|
2
|
+
|
|
3
|
+
Сканер измеряет слова автора. Листинг кода, markdown-цитата через «>» и короткая
|
|
4
|
+
цитата в ёлочках это чужие слова, их баны и маркеры не в счёт. Два релиза
|
|
5
|
+
подряд границы чинились регулярками по одному примеру, и каждый раз соседний
|
|
6
|
+
вариант той же ошибки оставался (ограда внутри строки кода, закрытие оградой
|
|
7
|
+
длиннее открывающей, продолжение цитаты без «>»). Здесь один построчный разбор
|
|
8
|
+
состояния по правилам CommonMark и таблица тестов на варианты в
|
|
9
|
+
scripts/test_markers.py.
|
|
10
|
+
|
|
11
|
+
Две проекции одного текста:
|
|
12
|
+
- lexical: чужое заменено заглушкой GAP посимвольно, переводы строк на месте.
|
|
13
|
+
Смещения и номера строк совпадают с исходником, фразовые регексы не
|
|
14
|
+
склеивают слова через заглушку (она не буква).
|
|
15
|
+
- prose: чужое вырезано целиком. Для ритма, морфологии и структуры: заглушки
|
|
16
|
+
ломали бы длину предложений и подсчёт абзацев.
|
|
17
|
+
|
|
18
|
+
Правила:
|
|
19
|
+
- Ограда кода: 3+ обратных кавычек или тильд с отступом до 3 пробелов в начале
|
|
20
|
+
строки. Закрывает ограда того же символа НЕ КОРОЧЕ открывающей. Кавычки внутри
|
|
21
|
+
строки кода оградой не являются. Незакрытая ограда не маскируется вовсе:
|
|
22
|
+
для сканера ложное срабатывание дешевле спрятанного абзаца.
|
|
23
|
+
- Цитата: строка с «>» в начале (отступ до 3 пробелов). Ленивое продолжение:
|
|
24
|
+
следующие непустые строки без «>» остаются цитатой, пока не встретится
|
|
25
|
+
пустая строка, заголовок, пункт списка или ограда.
|
|
26
|
+
- Инлайн-код: серия обратных кавычек закрывается серией той же длины в
|
|
27
|
+
пределах строки (`x`, ``a`b``).
|
|
28
|
+
- Короткая цитата в ёлочках (до QUOTE_MAX_WORDS слов) заглушается только в
|
|
29
|
+
lexical: так цитируют слово или оборот. Длинная остаётся под сканером, это
|
|
30
|
+
прямая речь или пересказ, её маркеры на совести автора. В prose ёлочки не
|
|
31
|
+
трогаем, они внутри предложений автора.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
|
|
38
|
+
GAP = "·"
|
|
39
|
+
QUOTE_MAX_WORDS = 12
|
|
40
|
+
|
|
41
|
+
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
42
|
+
_FENCE_CLOSE = re.compile(r"^ {0,3}(`{3,}|~{3,})\s*$")
|
|
43
|
+
_QUOTE_LINE = re.compile(r"^ {0,3}>")
|
|
44
|
+
# Строки, которые прерывают ленивое продолжение цитаты (CommonMark 5.1):
|
|
45
|
+
# заголовок, пункт списка, горизонтальная линия, ограда.
|
|
46
|
+
_BLOCK_START = re.compile(r"^ {0,3}(?:#{1,6}\s|[-*+]\s|\d{1,9}[.)]\s|(?:-{3,}|\*{3,}|_{3,})\s*$|`{3,}|~{3,})")
|
|
47
|
+
_INLINE_CODE = re.compile(r"(`+)(?!`)([^`\n]+?)\1(?!`)")
|
|
48
|
+
_QUOTE_MARKS = re.compile(r"«[^«»]*»")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _blank(s: str) -> str:
|
|
52
|
+
return re.sub(r"[^\n]", GAP, s)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fence_ranges(lines: list[str]) -> list[tuple[int, int]]:
|
|
56
|
+
"""Пары (первая, последняя) строк закрытых блоков кода, включая ограды."""
|
|
57
|
+
ranges: list[tuple[int, int]] = []
|
|
58
|
+
i = 0
|
|
59
|
+
while i < len(lines):
|
|
60
|
+
m = _FENCE_OPEN.match(lines[i])
|
|
61
|
+
# Инфо-строка ограды из кавычек не может содержать кавычки (CommonMark).
|
|
62
|
+
if not m or (m.group(1)[0] == "`" and "`" in m.group(2)):
|
|
63
|
+
i += 1
|
|
64
|
+
continue
|
|
65
|
+
ch, need = m.group(1)[0], len(m.group(1))
|
|
66
|
+
j = i + 1
|
|
67
|
+
while j < len(lines):
|
|
68
|
+
c = _FENCE_CLOSE.match(lines[j])
|
|
69
|
+
if c and c.group(1)[0] == ch and len(c.group(1)) >= need:
|
|
70
|
+
break
|
|
71
|
+
j += 1
|
|
72
|
+
if j >= len(lines):
|
|
73
|
+
i += 1 # незакрытая ограда: обычная строка
|
|
74
|
+
continue
|
|
75
|
+
ranges.append((i, j))
|
|
76
|
+
i = j + 1
|
|
77
|
+
return ranges
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _quote_lines(lines: list[str], in_code: set[int]) -> set[int]:
|
|
81
|
+
quoted: set[int] = set()
|
|
82
|
+
inside = False
|
|
83
|
+
for i, line in enumerate(lines):
|
|
84
|
+
if i in in_code:
|
|
85
|
+
inside = False
|
|
86
|
+
continue
|
|
87
|
+
if _QUOTE_LINE.match(line):
|
|
88
|
+
inside = True
|
|
89
|
+
quoted.add(i)
|
|
90
|
+
elif inside and line.strip() and not _BLOCK_START.match(line):
|
|
91
|
+
quoted.add(i) # ленивое продолжение абзаца цитаты
|
|
92
|
+
else:
|
|
93
|
+
inside = False
|
|
94
|
+
return quoted
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _classify(text: str) -> tuple[list[str], set[int], set[int]]:
|
|
98
|
+
lines = text.split("\n")
|
|
99
|
+
in_code: set[int] = set()
|
|
100
|
+
for a, b in _fence_ranges(lines):
|
|
101
|
+
in_code.update(range(a, b + 1))
|
|
102
|
+
return lines, in_code, _quote_lines(lines, in_code)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _blank_short_quote(m: re.Match[str]) -> str:
|
|
106
|
+
inner = m.group(0)[1:-1]
|
|
107
|
+
return _blank(m.group(0)) if len(inner.split()) <= QUOTE_MAX_WORDS else m.group(0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def mask_foreign(text: str) -> str:
|
|
111
|
+
"""lexical: код, цитаты и короткие ёлочки заглушены, смещения сохранены."""
|
|
112
|
+
lines, in_code, quoted = _classify(text)
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
for i, line in enumerate(lines):
|
|
115
|
+
if i in in_code or i in quoted:
|
|
116
|
+
out.append(_blank(line))
|
|
117
|
+
else:
|
|
118
|
+
out.append(_INLINE_CODE.sub(lambda m: _blank(m.group(0)), line))
|
|
119
|
+
return _QUOTE_MARKS.sub(_blank_short_quote, "\n".join(out))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def strip_foreign(text: str) -> str:
|
|
123
|
+
"""prose: код и цитаты вырезаны, инлайн-код удалён, ёлочки на месте."""
|
|
124
|
+
lines, in_code, quoted = _classify(text)
|
|
125
|
+
kept = [_INLINE_CODE.sub("", line) for i, line in enumerate(lines)
|
|
126
|
+
if i not in in_code and i not in quoted]
|
|
127
|
+
return "\n".join(kept)
|