humanizer-ru 3.14.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.
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """humanizer_ru — CLI-сканеры следов машинной генерации русского текста.
3
+
4
+ Пакет копирует два скрипта из корневого каталога scripts/: check_markers.py
5
+ и scan_soft_signals.py. Копии байтово синхронизируются гейтом
6
+ scripts/check_pkg_sync.py.
7
+ """
8
+ __all__ = ["__version__"]
9
+ __version__ = "3.14.0"
@@ -0,0 +1,615 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Прогон регулярных выражений из references/chatbot-artifacts.md
4
+ по проверочным образцам из references/test-fixtures.md.
5
+
6
+ Каждое выражение проходит три уровня:
7
+ 1. Прямой образец — выражение обязано сработать.
8
+ 2. Отрицательный — похожий, но не машинный текст; срабатывать не должно.
9
+ 3. Граничный — пустая строка и многократные совпадения; без падений.
10
+
11
+ Только стандартная библиотека. Запуск: python3 scripts/check_markers.py
12
+ Код возврата 0 — все проверки пройдены, 1 — есть провалы.
13
+
14
+ При добавлении нового выражения в chatbot-artifacts.md сюда добавляется
15
+ блок с образцами, а парные образцы — в references/test-fixtures.md.
16
+ Ни одно работающее правило не удаляется (см. принцип в test-fixtures.md).
17
+ """
18
+
19
+ import glob
20
+ import re
21
+ import sys
22
+
23
+ # Консоли Windows (cp866/cp1251/ascii) не должны ронять валидатор на кириллице.
24
+ if hasattr(sys.stdout, "reconfigure"):
25
+ sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
26
+ sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace")
27
+
28
+ # name: (выражение, прямые образцы, отрицательные образцы, [многократный образец, ожидаемое число])
29
+ CASES = {
30
+ # --- A.1. Метки внутреннего цитирования OpenAI ---
31
+ "contentReference": (
32
+ r":contentReference\[oaicite:\d+\]\{index=\d+\}",
33
+ ["Согласно отчёту :contentReference[oaicite:0]{index=0}, рынок вырос на 12%.",
34
+ ":contentReference[oaicite:42]{index=42}"],
35
+ ["Это просто ссылка на oaicite (упоминание термина в статье об ИИ).",
36
+ "Документация: содержит [oaicite:N] как пример формата."],
37
+ (":contentReference[oaicite:0]{index=0} и :contentReference[oaicite:1]{index=1} и :contentReference[oaicite:2]{index=2}", 3),
38
+ ),
39
+ "oai_citation": (
40
+ r"oai_citation:\d+‡",
41
+ ["Источник oai_citation:5‡Wikipedia говорит, что…", "oai_citation:0‡"],
42
+ ["oai_citation без числа после двоеточия"],
43
+ None,
44
+ ),
45
+ "oaicite_short": (
46
+ r"oaicite:\d+",
47
+ ["усечённая ссылка oaicite:7"],
48
+ ["oaicite без двоеточия и числа"],
49
+ None,
50
+ ),
51
+ # --- A.2. Метки веб-поиска OpenAI ---
52
+ "turn_search": (
53
+ r"turn\d+search\d+",
54
+ ["Согласно turn0search0, тема актуальна.", "turn3search12 в середине предложения"],
55
+ ["turn left and search again", "turnaround search"],
56
+ ("turn0search0 turn1search1 turn2search2", 3),
57
+ ),
58
+ "turn_fetch": (
59
+ r"turn\d+fetch\d+",
60
+ ["[turn0fetch0] в скобках"],
61
+ ["turn fetch the file"],
62
+ None,
63
+ ),
64
+ "turn_file": (
65
+ r"turn\d+file\d+",
66
+ ["Вывод обрывается метками fileciteturn0file2turn0file6 в конце.",
67
+ "одиночная метка turn0file11 после цитаты"],
68
+ ["turn the file over", "return file 5 to the archive"],
69
+ ("fileciteturn0file2turn0file6", 2),
70
+ ),
71
+ "ref_name_search": (
72
+ r"<ref\b[^>]*\bname=[\"']\d+(?:search|fetch|file|image|news|video|ref)\d+[\"']",
73
+ [
74
+ '<ref name="0search12">',
75
+ '<ref name="2file0">',
76
+ '<ref name="1news4" />',
77
+ ],
78
+ [
79
+ '<ref name="search12">',
80
+ '<ref name="source12">',
81
+ '<ref name="turn0search0">',
82
+ 'ref name="0search12" без тега ref',
83
+ ],
84
+ ('<ref name="0search0"> <ref name="0search1"> <ref name="2file3">', 3),
85
+ ),
86
+ # --- A.3. Метки UTM от чат-ботов ---
87
+ "utm_chatgpt": (
88
+ r"[?&]utm_source=chatgpt\.com",
89
+ ["https://example.com/article?utm_source=chatgpt.com",
90
+ "https://example.com/?id=5&utm_source=chatgpt.com",
91
+ "?utm_source=chatgpt.com&other=1"],
92
+ ["utm_source=chatgpt.com упомянут в статье об отслеживании",
93
+ "https://example.com/?utm_source=other.com"],
94
+ None,
95
+ ),
96
+ "utm_openai": (
97
+ r"[?&]utm_source=openai",
98
+ ["https://docs.example.com/?utm_source=openai"],
99
+ ["OpenAI utm_source без знака ? или &"],
100
+ None,
101
+ ),
102
+ "utm_copilot": (
103
+ r"[?&]utm_source=copilot\.com",
104
+ ["https://example.com/?utm_source=copilot.com",
105
+ "https://example.com/?id=7&utm_source=copilot.com"],
106
+ ["utm_source=copilot.com упомянут в статье об отслеживании",
107
+ "https://example.com/?utm_source=chatgpt.com"],
108
+ None,
109
+ ),
110
+ "grok_referrer": (
111
+ r"[?&]referrer=grok\.com",
112
+ ["https://x.com/post?referrer=grok.com",
113
+ "https://example.com/?id=1&referrer=grok.com"],
114
+ ["referrer=grok.com упомянут без URL-параметра",
115
+ "https://example.com/?referrer=other.com"],
116
+ None,
117
+ ),
118
+ # --- A.4. Метки прикрепления и карточек ---
119
+ "attached_file": (
120
+ r"attached_file:\/\/",
121
+ ["См. attached_file:///tmp/upload.pdf"],
122
+ ["Файл прикреплён, см. attached file (по-русски)"],
123
+ None,
124
+ ),
125
+ "grok_card": (
126
+ r"grok_card:\/\/",
127
+ ["grok_card://1234567890"],
128
+ ["карточка Grok без специфичной разметки"],
129
+ None,
130
+ ),
131
+ "grok_render_json": (
132
+ r"grok_render_citation_card_json",
133
+ ['[](grok_render_citation_card_json={"cardIds":["3bb883"]})'],
134
+ ["grok render citation card json обсуждают в документации"],
135
+ ('[](grok_render_citation_card_json={"cardIds":["1"]}) [](grok_render_citation_card_json={"cardIds":["2"]})', 2),
136
+ ),
137
+ "vertexaisearch": (
138
+ r"vertexaisearch\.cloud\.google\.com/grounding-api-redirect",
139
+ # Схема https:// в образце опущена намеренно: выражению нужен host+path
140
+ # самой приметы, а полный URL в поставляемом файле сканеры безопасности
141
+ # принимают за канал раздачи. Подлинная форма — в research/ и tests/,
142
+ # они в архив скилла не входят.
143
+ ["ссылка vertexaisearch.cloud.google.com/grounding-api-redirect/AbCdEf в тексте"],
144
+ ["vertexaisearch.cloud.google.com без пути",
145
+ "обычная страница продукта cloud.google.com/vertex-ai-search"],
146
+ None,
147
+ ),
148
+ # --- A.5. Прочие маркеры разметки ---
149
+ "attributableIndex": (
150
+ r"\battributableIndex\b",
151
+ ['{"attributableIndex": 0}'],
152
+ ["слово attributable в обычном тексте о праве и атрибуции",
153
+ "attributableIndexes (с окончанием)"],
154
+ None,
155
+ ),
156
+ "citation_n": (
157
+ r"\[citation:\d+\]",
158
+ ["Согласно исследованию [citation:3], результаты неоднозначны."],
159
+ ["[citation needed] (Википедийный шаблон)"],
160
+ None,
161
+ ),
162
+ # --- A.6. Маркеры новых платформ (v2.5) ---
163
+ "copilot_caret": (
164
+ r"\[\^\d+\^\]",
165
+ ["Рынок вырос на 12%[^1^] по данным отчёта.", "[^10^]"],
166
+ ["Обычная сноска Markdown[^1] определена ниже."],
167
+ ("[^1^][^2^][^10^]", 3),
168
+ ),
169
+ "assistants_source": (
170
+ r"【\d+(?::\d+)?†source】",
171
+ ["Согласно политике【1†source】, доступ разрешён.", "【4:2†source】"],
172
+ ["Декоративные уголки 【примечание】 без кинжала."],
173
+ None,
174
+ ),
175
+ "cite_turn": (
176
+ r"citeturn\d+[a-z]+\d+",
177
+ ["Текст citeturn0file0 со ссылкой.", "citeturn2search5 в середине строки"],
178
+ ["Прошу процитировать, затем turn to page 5."],
179
+ ("citeturn0file0 citeturn2search5", 2),
180
+ ),
181
+ "sandbox_link": (
182
+ r"\]\(sandbox:/mnt/data/",
183
+ ["[Скачать отчёт](sandbox:/mnt/data/report.xlsx)"],
184
+ ["Развернули окружение sandbox на /mnt/data сервера."],
185
+ ("[A](sandbox:/mnt/data/a.csv) [B](sandbox:/mnt/data/b.csv)", 2),
186
+ ),
187
+ # --- A.7. Невидимые и служебные символы (v2.6) ---
188
+ "openai_pua": (
189
+ "[\ue200-\ue204]",
190
+ ["Amazon Nova даёт ряд возможностей \ue200cite\ue202turn0search3\ue201.",
191
+ "скрытый блок \ue203служебная пометка\ue204 в тексте"],
192
+ ["Обычный текст без служебных символов.",
193
+ "Символ иконки \ue000 из шрифтового набора (другая часть PUA)"],
194
+ ("\ue200cite\ue202turn0search3\ue201", 3),
195
+ ),
196
+ # Короткая форма сноски ChatGPT: одиночная цифра, ограждённая U+EA01/U+EA02 (v3.2)
197
+ "openai_pua_short": (
198
+ "[\uea01\uea02]",
199
+ ["известная по ролям в сериале.\uea012\uea02",
200
+ "высокий рейтинг на агрегаторе.\uea015\uea02 Следующее предложение."],
201
+ ["Обычный текст без служебных символов с цифрой 2 в конце.",
202
+ "Символ иконки \ue000 из шрифтового набора (другая часть PUA)",
203
+ "Служебный разделитель \ue200 из длинной формы (своё выражение openai_pua)"],
204
+ ("Первое.\uea012\uea02 Второе.\uea013\uea02", 4),
205
+ ),
206
+ # Только на границе строки. Упоминание тега внутри связного предложения
207
+ # («у модели есть служебный тег <think>») — это текст О reasoning-моделях,
208
+ # а не их вывод. Осознанная потеря recall в середине строки: цена за то,
209
+ # чтобы техническая статья не помечалась как машинная.
210
+ "think_tag": (
211
+ r"(?m)^\s*</?think>|</think>\s*$",
212
+ ["<think>Сначала разберу условия задачи…</think> Ответ: 42.",
213
+ "</think> Итоговый ответ ниже."],
214
+ ["Я думаю (think), что это норма.",
215
+ "Тег <thinking> другого формата здесь не считается.",
216
+ "У некоторых моделей есть служебный тег <think> для черновика."],
217
+ ("<think>а</think>", 2),
218
+ ),
219
+ # --- A.8. Сцепки «Источник+цифра» (v2.7) ---
220
+ # Требуются не меньше двух сегментов «+число»: одиночная склейка вида
221
+ # «Excel+1С» встречается в живой речи об офисных программах и тарифах.
222
+ # Цена сужения: одиночную склейку выражение больше не ловит, её ищут глазами.
223
+ "source_plus_chain": (
224
+ r"[A-Za-zА-Яа-яЁё)]\+\d+(?=[A-ZА-ЯЁ][A-Za-zА-Яа-яЁё&.\-]*"
225
+ r"(?: [A-ZА-ЯЁ][A-Za-zА-Яа-яЁё&.\-]*){0,3}\+\d)",
226
+ ["Стандарт создан комитетом ISO. IT Governance+3ISO+3ISO+3. Он входит в семейство…",
227
+ "адаптирован к облачным средам. Microsoft Learn+3Google Cloud+3."],
228
+ ["стандарт C++11 и C++14 поддерживаются",
229
+ "формула x+1 в каждой строке",
230
+ "оценка 5+ за контрольную",
231
+ "Wikipedia+1.",
232
+ "связка Excel+1С в резюме",
233
+ "пакет Word+2Excel для офиса",
234
+ "тариф Про+3Максимум на месяц",
235
+ "Excel+1С и формула x+1 рядом"],
236
+ ("Wikipedia+1Реестр+2Архив+3", 2),
237
+ ),
238
+ # --- A.9. Метки цитирования Gemini (v2.9) ---
239
+ "gemini_cite_start": (
240
+ r"\[cite_start\]",
241
+ ["[cite_start]Компания основана в 1994 году и с тех пор…",
242
+ "Вывод содержит [cite_start] в середине строки."],
243
+ ["функция cite_start() в коде без скобок вокруг",
244
+ "[cite start] с пробелом вместо подчёркивания"],
245
+ ("[cite_start]Первое. [cite_start]Второе.", 2),
246
+ ),
247
+ "gemini_cite_n": (
248
+ r"\[[Cc]ite:\s?\d+(?:,\s?\d+)*\]",
249
+ ["Выручка выросла на 12% [cite: 8] по итогам года.",
250
+ "Согласно отчёту [Cite: 12], план выполнен.",
251
+ "Работала с клиентами в медицине и недвижимости [cite: 19, 20, 21]."],
252
+ ["[citation needed] (Википедийный шаблон)",
253
+ "[citation:3] — метка DeepSeek, у неё своё выражение",
254
+ "команда \\cite{ivanov2024} в LaTeX"],
255
+ ("[cite: 1][cite: 2][Cite: 3]", 3),
256
+ ),
257
+ # --- A.9 доп. span-метки Gemini (v3.5) ---
258
+ "gemini_span": (
259
+ r"\[span_\d+\][\[(](?:start_span|end_span)[\])]",
260
+ ["Альбом вышел[span_2](start_span) в августе 1986[span_2](end_span).",
261
+ "Форма заголовка раздела: [span_1][start_span]",
262
+ "Текст [span_12](start_span)фрагмент[span_12](end_span) целиком."],
263
+ ["[span_2] без start_span/end_span",
264
+ "[start_span] без номера span",
265
+ "[span_A](start_span) — буква вместо номера"],
266
+ ("[span_3](start_span)Альбом[span_3](end_span) и [span_4](start_span)сингл[span_4](end_span)", 4),
267
+ ),
268
+ # --- A.10. Символы нулевой ширины (v2.9) ---
269
+ # ZWJ (U+200D) — легальный символ составных эмодзи: семья, флаги,
270
+ # профессии. Он считается маркером только вне эмодзи-контекста;
271
+ # остальные символы нулевой ширины ловятся как раньше.
272
+ "zero_width": (
273
+ # Биди-контролы (U+200E/200F, U+202A–U+202E, U+2066–U+2069),
274
+ # невидимые операторы (U+2061–U+2064) и межстрочные аннотации
275
+ # (U+FFF9–U+FFFB) — тот же класс скрытой разметки, что и нулевые
276
+ # пробелы: в скопированном тексте они не возникают сами (v3.11,
277
+ # классы меток watermarks-remover, публичный разбор 2026-08-13).
278
+ "[\u200b\u200c\u200e\u200f\u202a-\u202e\u2060-\u2064"
279
+ "\u2066-\u2069\ufeff\ufff9-\ufffb]|"
280
+ "(?<![\U0001F000-\U0001FAFF\u2600-\u27BF\uFE0F\U0001F1E6-\U0001F1FF])"
281
+ "\u200d"
282
+ "(?![\U0001F000-\U0001FAFF\u2600-\u27BF\uFE0F\U0001F1E6-\U0001F1FF])",
283
+ ["\u0421\u043b\u043e\u0432\u043e\u200b\u0440\u0430\u0437\u043e\u0440\u0432\u0430\u043d\u043e \u043f\u0440\u043e\u0431\u0435\u043b\u043e\u043c \u043d\u0443\u043b\u0435\u0432\u043e\u0439 \u0448\u0438\u0440\u0438\u043d\u044b.",
284
+ "\u041d\u0435\u0432\u0438\u0434\u0438\u043c\u044b\u0439 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u044c\u2060\u0432\u043d\u0443\u0442\u0440\u0438 \u0442\u0435\u043a\u0441\u0442\u0430.",
285
+ "\u041c\u0435\u0442\u043a\u0430\ufeff\u043f\u043e\u0441\u0440\u0435\u0434\u0438 \u0441\u0442\u0440\u043e\u043a\u0438.",
286
+ "\u0441\u043b\u043e\u200d\u0432\u043e \u0441 \u0441\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435\u043b\u0435\u043c \u0432\u043d\u0443\u0442\u0440\u0438 \u043a\u0438\u0440\u0438\u043b\u043b\u0438\u0446\u044b"],
287
+ ["\u041e\u0431\u044b\u0447\u043d\u044b\u0439 \u0442\u0435\u043a\u0441\u0442 \u0441 \u043e\u0431\u044b\u0447\u043d\u044b\u043c\u0438 \u043f\u0440\u043e\u0431\u0435\u043b\u0430\u043c\u0438.",
288
+ "\u0423\u0437\u043a\u0438\u0439 \u043d\u0435\u0440\u0430\u0437\u0440\u044b\u0432\u043d\u044b\u0439 \u043f\u0440\u043e\u0431\u0435\u043b\u202f\u043d\u0435 \u0432\u0445\u043e\u0434\u0438\u0442 \u0432 \u0432\u044b\u0440\u0430\u0436\u0435\u043d\u0438\u0435 (\u0440\u0443\u0447\u043d\u0430\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430).",
289
+ "\u0441\u0435\u043c\u044c\u044f \U0001F468\u200d\U0001F469\u200d\U0001F467 \u0432 \u043f\u043e\u0434\u043f\u0438\u0441\u0438 \u043a \u0444\u043e\u0442\u043e",
290
+ "\u0444\u043b\u0430\u0433 \U0001F3F3\uFE0F\u200d\U0001F308 \u0432 \u0447\u0430\u0442\u0435",
291
+ "\u043a\u043e\u0441\u043c\u043e\u043d\u0430\u0432\u0442 \U0001F469\u200d\U0001F680 \u043d\u0430 \u0430\u0432\u0430\u0442\u0430\u0440\u043a\u0435"],
292
+ ("\u0430\u200b\u0431\u200c\u0432\u200d\u0433", 3),
293
+ ),
294
+ # --- A.11. Блоки «writing» ChatGPT (v3.0) ---
295
+ "writing_block": (
296
+ r":::\w+\{variant",
297
+ [':::writing{variant="document" id="68427"}',
298
+ 'Черновик письма ниже. :::writing{variant="email" id="51724"}',
299
+ ':::écriture{variante="document" id="28471"}'],
300
+ [":::note — директива Docusaurus без атрибута variant",
301
+ ':::tip{title="Совет"} — атрибут другой',
302
+ "обычное троеточие в конце фразы..."],
303
+ (':::writing{variant="email" id="11111"} текст ::: :::writing{variant="chat_message" id="22222"}', 2),
304
+ ),
305
+ # --- A.12. DeepSeek/derivative line references (v3.1) ---
306
+ "deepseek_line_ref": (
307
+ r"【\d+†L\d+(?:-L?\d+)?】",
308
+ ["В 2024 году кампания выросла【85†L261-269】.",
309
+ "Альбом вышел【854†L119-123】 в 2024 году.",
310
+ "【854†L119-L123】 — форма с L после дефиса."],
311
+ ["OpenAI Assistants метка 【1†source】 ловится другим выражением.",
312
+ "Декоративные уголки 【примечание】 без строк.",
313
+ "Обычная сноска [1] не должна срабатывать."],
314
+ ("【29†L582-589】【32†L142-149】", 2),
315
+ ),
316
+ # --- A.4 доп. Grok XML-тег (v3.1) ---
317
+ "grok_card_tag": (
318
+ r"<grok-card\b[^>]*\bcitation_card\b",
319
+ ['<grok-card data-id="e8ff4f" data-type="citation_card">',
320
+ '\u0422\u0435\u043a\u0441\u0442...<grok-card data-id="abc" data-type="citation_card">'],
321
+ ["<grok-card> \u0431\u0435\u0437 \u0430\u0442\u0440\u0438\u0431\u0443\u0442\u0430 citation_card",
322
+ "\u043e\u0431\u044b\u0447\u043d\u044b\u0439 XML-\u0442\u0435\u0433 <div>"],
323
+ None,
324
+ ),
325
+ # --- A.2 доп. turn image/news/video/ref (v3.1) ---
326
+ "turn_other": (
327
+ r"turn\d+(?:image|news|video|ref)\d+",
328
+ ["\u0424\u043e\u0442\u043e turn0image0 \u0432 \u0442\u0435\u043a\u0441\u0442\u0435.", "turn0news0 \u0432 \u0441\u0435\u0440\u0435\u0434\u0438\u043d\u0435", "turn0video0", "turn0ref0"],
329
+ ["turn left and image again", "turnaround news"],
330
+ ("turn0image0 turn0news0 turn0video0", 3),
331
+ ),
332
+ # --- A.5 доп. скобочная форма ссылок инструментов (v3.1) ---
333
+ "attached_web_bracket": (
334
+ r"\[(?:attached_file|web):\d+\]",
335
+ ["\u0421\u043c. [attached_file:1] \u0432 \u043e\u0442\u0432\u0435\u0442\u0435.", "\u0426\u0438\u0442\u0430\u0442\u0430 [web:3] \u0438\u0437 \u043f\u043e\u0438\u0441\u043a\u0430."],
336
+ ["[attach:1] \u0434\u0440\u0443\u0433\u043e\u0439 \u0444\u043e\u0440\u043c\u0430\u0442", "\u043e\u0431\u044b\u0447\u043d\u0430\u044f \u0441\u043d\u043e\u0441\u043a\u0430 [1]"],
337
+ ("[attached_file:1][web:2][web:3]", 3),
338
+ ),
339
+ # --- A.5 доп. S3-ссылки Perplexity (v3.5) ---
340
+ "perplexity_s3": (
341
+ r"ppl-ai-file-upload",
342
+ # Схема https:// в образцах опущена намеренно: выражению нужен только
343
+ # идентификатор бакета ppl-ai-file-upload, а полный адрес S3 в
344
+ # поставляемом файле сканеры безопасности принимают за канал раздачи
345
+ # файлов. Подлинная форма приметы — в tests/fixtures/perplexity-s3.txt
346
+ # и в реестре research/fixtures/marker-sources.json; они в архив скилла
347
+ # не входят. Отрицательный образец сохраняет смысл: другой бакет
348
+ # (other-bucket) на том же сервисе под выражение не подпадает.
349
+ ["Источник: ссылка на бакет ppl-ai-file-upload в пути (сервис s3.amazonaws)",
350
+ "Ссылка на бакет ppl-ai-file-upload после имени сервиса s3.amazonaws в списке литературы."],
351
+ ["упоминание ppl ai file upload с пробелами",
352
+ "обычная ссылка на другой бакет other-bucket на сервисе s3.amazonaws"],
353
+ None,
354
+ ),
355
+ # --- A.6 доп. generated-reference-identifier (v3.1) ---
356
+ "generated_ref_id": (
357
+ r"citegenerated-reference-identifier",
358
+ ["\u0422\u0435\u043a\u0441\u0442 citegenerated-reference-identifier \u0432 \u0432\u044b\u0432\u043e\u0434\u0435."],
359
+ ["generated reference identifier \u0447\u0435\u0440\u0435\u0437 \u043f\u0440\u043e\u0431\u0435\u043b"],
360
+ None,
361
+ ),
362
+ # --- A.5 доп. placeholder URLs (v3.1) ---
363
+ "placeholder_url": (
364
+ r"\b(?:INSERT_SOURCE_URL(?:_\d+)?|URL_HERE|PASTE_\w+_URL_HERE)\b",
365
+ ["\u0412\u0441\u0442\u0430\u0432\u044c\u0442\u0435 INSERT_SOURCE_URL_30 \u0441\u044e\u0434\u0430.", "\u0421\u043c. URL_HERE \u0432 \u0448\u0430\u0431\u043b\u043e\u043d\u0435.", "PASTE_SPOTIFY_TRACK_URL_HERE"],
366
+ ["insert source url \u0432 \u043e\u0431\u044b\u0447\u043d\u043e\u0439 \u0444\u0440\u0430\u0437\u0435", "\u0432\u0441\u0442\u0430\u0432\u044c\u0442\u0435 URL \u0441\u044e\u0434\u0430"],
367
+ ("INSERT_SOURCE_URL URL_HERE PASTE_TRACK_URL_HERE", 3),
368
+ ),
369
+ # --- A.5 доп. placeholder-даты (v3.1) ---
370
+ # Год ограничен формами 19xx/20xx, месяц — 01-12 либо XX. Складские и
371
+ # товарные номера вида «1234-56-xx», «3985-77-XX» под выражение больше не
372
+ # попадают; невозможный месяц («2025-13-XX») тоже отсекается.
373
+ "placeholder_date": (
374
+ r"\b(?:19|20)\d{2}-(?:0[1-9]|1[0-2]|[Xx]{2})-[Xx]{2}\b",
375
+ ["дата обращения: 2025-XX-XX.",
376
+ "|date=2022-11-XX |publisher=…",
377
+ "access-date=2025-xx-xx"],
378
+ ["обычная дата 2025-11-30 не срабатывает",
379
+ "артикул 2025-XX-XXL не срабатывает",
380
+ "диапазон 2024-2025 без дня",
381
+ "код изделия 1234-56-xx",
382
+ "серия 3985-77-XX",
383
+ "невозможный месяц 2025-13-XX"],
384
+ ("2025-XX-XX и 2022-11-XX в одном списке литературы", 2),
385
+ ),
386
+ # --- B. Невидимая раскладка: мягкий перенос, наборные пробелы,
387
+ # вариационные селекторы вне эмодзи (v3.11) ---
388
+ # Класс B: каждый символ встречается и в легальной вёрстке, но в
389
+ # скопированном тексте это артефакт. NBSP (U+00A0) и узкий NBSP
390
+ # (U+202F) не входят — это норма русской типографики; VS16 (U+FE0F)
391
+ # в эмодзи легален и пропускается тем же правилом, что ZWJ в A.7.
392
+ "invisible_layout": (
393
+ # Наборные пробелы U+2000–U+200A в маркер НЕ входят: они легитимны
394
+ # в оцифрованных русских классиках (Викитека: тонкий пробел U+2009
395
+ # в Перельмане) — человеческий корпус это поймал сразу. Остаются
396
+ # только пробелы, которых в русской типографике не бывает.
397
+ "\u00ad|[\u1680\u205f\u3000]|"
398
+ "(?<![\U0001F000-\U0001FAFF\u2600-\u27BF\U0001F1E6-\U0001F1FF])"
399
+ "[\ufe00-\ufe0f]"
400
+ "(?![\U0001F000-\U0001FAFF\u2600-\u27BF\U0001F1E6-\U0001F1FF])",
401
+ ["мягкий перенос сло\u00adво",
402
+ "огхамский пробел: а\u1680б",
403
+ "идеографический пробел: а\u3000б",
404
+ "средний математический пробел: а\u205fб",
405
+ "вариационный селектор вне эмодзи: текст\ufe0f."],
406
+ ["обычный неразрывный пробел\u00a0внутри предложения",
407
+ "узкий неразрывный пробел\u202fпосле числа",
408
+ "наборный пробел: а\u2002б",
409
+ "тонкий пробел: а\u2009б",
410
+ "сердце ❤\ufe0f и солнце ☀\ufe0f в чате",
411
+ "светофор 🚦 без селектора"],
412
+ ("а\u00adб и в\u1680г и д\ufe0fе", 3),
413
+ ),
414
+ }
415
+
416
+
417
+ def _inside_backticks(line: str, start: int, end: int) -> bool:
418
+ """Совпадение внутри `обратных кавычек` — это документация, не артефакт."""
419
+ return line[:start].count("`") % 2 == 1 and line[end:].count("`") >= 1
420
+
421
+
422
+ def _line_matches(line: str, compiled: dict) -> list:
423
+ """Совпадения всех выражений в строке без вложенных дублей.
424
+
425
+ Одна примета может совпасть с несколькими выражениями сразу:
426
+ «citeturn0file0» ловится и cite_turn, и turn_file, а полная форма
427
+ «:contentReference[oaicite:0]{index=0}» — ещё и усечённой oaicite_short.
428
+ В отчёте это раздувало счёт: один артефакт печатался два-три раза.
429
+ Совпадение, целиком лежащее внутри более длинного совпадения другого
430
+ выражения, отбрасывается; пересечения без вложенности (PUA-разделители
431
+ вокруг turn-метки) сохраняются. На вердикт это не влияло — любое
432
+ совпадение класса A достаточно, — но счёт в отчёте был завышен.
433
+ """
434
+ found = []
435
+ for name, rx in compiled.items():
436
+ for m in rx.finditer(line):
437
+ if _inside_backticks(line, m.start(), m.end()):
438
+ continue
439
+ found.append((m.start(), m.end(), name))
440
+ kept = [(s, e, name) for s, e, name in found
441
+ if not any(s2 <= s and e <= e2 and (e - s) < (e2 - s2)
442
+ for s2, e2, _n in found)]
443
+ kept.sort()
444
+ return kept
445
+
446
+
447
+ def _console_text(text: str, encoding=None) -> str:
448
+ """Сохраняет диагностику читаемой и на консолях без поддержки PUA/BOM."""
449
+ encoding = encoding or sys.stdout.encoding or "utf-8"
450
+ return text.encode(encoding, errors="backslashreplace").decode(encoding)
451
+
452
+
453
+ def scan(paths: list) -> int:
454
+ """Прогон всех выражений по произвольным файлам.
455
+
456
+ Запуск: python3 scripts/check_markers.py --scan файл1 [файл2 …]
457
+ Печатает каждое совпадение в формате «файл:строка [имя] фрагмент».
458
+ Совпадения внутри обратных кавычек пропускаются (документация выражений),
459
+ вложенные дубли одного артефакта схлопываются (см. _line_matches).
460
+ Код возврата 0 — чисто, 1 — найдены маркеры, 2 — файл не читается.
461
+ """
462
+ compiled = {name: re.compile(case[0]) for name, case in CASES.items()}
463
+ found = 0
464
+ for path in paths:
465
+ try:
466
+ with open(path, encoding="utf-8") as fh:
467
+ lines = fh.read().splitlines()
468
+ except (OSError, UnicodeDecodeError) as exc:
469
+ print(f"Не удалось прочитать {path}: {exc}", file=sys.stderr)
470
+ return 2
471
+ for lineno, line in enumerate(lines, 1):
472
+ for _start, _end, name in _line_matches(line, compiled):
473
+ found += 1
474
+ fragment = _console_text(line.strip()[:90])
475
+ print(f"{path}:{lineno} [{name}] {fragment}")
476
+ if found:
477
+ print(f"\nНайдено маркеров: {found}.")
478
+ return 1
479
+ print("Маркеров не найдено.")
480
+ return 0
481
+
482
+
483
+ def main() -> int:
484
+ fails = 0
485
+ for name, (pattern, positives, negatives, multi) in CASES.items():
486
+ rx = re.compile(pattern)
487
+ for s in positives:
488
+ if not rx.search(s):
489
+ print(f"ПРОВАЛ {name}: прямой образец не пойман: {s!r}")
490
+ fails += 1
491
+ for s in negatives:
492
+ if rx.search(s):
493
+ print(f"ПРОВАЛ {name}: ложное срабатывание на: {s!r}")
494
+ fails += 1
495
+ if rx.search(""):
496
+ print(f"ПРОВАЛ {name}: срабатывание на пустой строке")
497
+ fails += 1
498
+ if multi is not None:
499
+ text, expected = multi
500
+ got = len(rx.findall(text))
501
+ if got != expected:
502
+ print(f"ПРОВАЛ {name}: многократный образец — ожидалось {expected}, найдено {got}")
503
+ fails += 1
504
+
505
+ if _console_text("\ufeff", "ascii") != r"\ufeff":
506
+ print("ПРОВАЛ scan: невидимый символ не экранируется для ASCII-консоли")
507
+ fails += 1
508
+
509
+ # Отчёт --scan не должен печатать один артефакт дважды из-за вложенных
510
+ # выражений — и обязан сохранять пересечения без вложенности
511
+ # (PUA-разделители рядом с turn-меткой считаются отдельно).
512
+ compiled_all = {name: re.compile(case[0]) for name, case in CASES.items()}
513
+ for text, expected in (
514
+ (":contentReference[oaicite:0]{index=0}", 1),
515
+ ("fileciteturn0file2turn0file6", 2),
516
+ ("\ue200cite\ue202turn0search3\ue201", 4),
517
+ ("обычная строка без примет", 0),
518
+ ):
519
+ got = len(_line_matches(text, compiled_all))
520
+ if got != expected:
521
+ print("ПРОВАЛ scan-дедупликация: ожидалось %d, найдено %d для %r"
522
+ % (expected, got, _console_text(text[:40])))
523
+ fails += 1
524
+
525
+ total = len(CASES)
526
+ if fails:
527
+ print(f"\nИтог: {total} выражений, провалов: {fails}.")
528
+ return 1
529
+ print(f"Итог: {total} из {total} выражений проходят все проверки.")
530
+ return 0
531
+
532
+
533
+ def _canon_pattern(text: str) -> str:
534
+ """Каноническая форма для сравнения regex в коде и в markdown-таблицах."""
535
+ text = text.replace("\\|", "|").replace('\\"', '"').replace("\\'", "'")
536
+ out = []
537
+ for c in text:
538
+ o = ord(c)
539
+ if o < 128:
540
+ out.append(c)
541
+ elif o <= 0xFFFF:
542
+ out.append("\\u%04x" % o)
543
+ else:
544
+ out.append("\\U%08x" % o)
545
+ return "".join(out)
546
+
547
+
548
+ def parity(*md_paths: str) -> int:
549
+ """Проверка md↔py паритета: каждое выражение CASES задокументировано в справочнике.
550
+
551
+ Справочник разбит на семейство файлов `references/chatbot-artifacts*.md`;
552
+ без явно переданных путей читаются все файлы семейства, и каждое выражение
553
+ обязано найтись хотя бы в одном.
554
+ В markdown-таблицах вертикальная черта экранируется как `\\|`, а невидимые
555
+ символы записаны escape-последовательностями, поэтому обе стороны
556
+ нормализуются через _canon_pattern. Код возврата 0 — все выражения
557
+ задокументированы, 1 — есть недокументированные (regex без описания),
558
+ 2 — файлы семейства не найдены или не читаются.
559
+ """
560
+ if not md_paths:
561
+ md_paths = tuple(sorted(glob.glob("references/chatbot-artifacts*.md")))
562
+ if not md_paths:
563
+ print("Не найдены файлы references/chatbot-artifacts*.md", file=sys.stderr)
564
+ return 2
565
+ parts = []
566
+ for md_path in md_paths:
567
+ try:
568
+ with open(md_path, encoding="utf-8") as fh:
569
+ parts.append(_canon_pattern(fh.read()))
570
+ except (OSError, UnicodeDecodeError) as exc:
571
+ print(f"Не удалось прочитать {md_path}: {exc}", file=sys.stderr)
572
+ return 2
573
+ doc = "\n".join(parts)
574
+ missing, drift = [], []
575
+ for name, case in CASES.items():
576
+ pat = _canon_pattern(case[0])
577
+ if pat not in doc:
578
+ missing.append(name)
579
+ continue
580
+ # Файл, упоминающий кейс как идентификатор (полный спан в
581
+ # обратных кавычках), обязан нести его точное выражение: порча
582
+ # одной из дублированных строк роняет гейт, а не проходит за
583
+ # счёт целого двойника в соседнем файле. Упоминания имени
584
+ # внутри литералов и прозы не считаются (урок rev6: имена
585
+ # кейсов входят в сами маркеры вроде `:contentReference[...`).
586
+ id_ref = "`" + name + "`"
587
+ for md_path, part in zip(md_paths, parts):
588
+ # Проверка построчная: каждая табличная строка, называющая
589
+ # кейс полным идентификатором, обязана нести его точное
590
+ # выражение в этой же строке. Корректный паттерн в прозе
591
+ # того же файла испорченную строку не оправдывает (rev7),
592
+ # а порча единственной строки не прячется за двойником.
593
+ for line in part.splitlines():
594
+ if line.lstrip().startswith("|") and id_ref in line \
595
+ and pat not in line:
596
+ drift.append((name, md_path))
597
+ for name in missing:
598
+ print(f"ПРОВАЛ parity: {name} отсутствует в {', '.join(md_paths)}")
599
+ for name, md_path in drift:
600
+ print(f"ПРОВАЛ parity: табличная строка с {name} в {md_path} "
601
+ f"не несёт точного выражения")
602
+ if missing or drift:
603
+ print(f"Паритет: {len(CASES) - len(missing)}/{len(CASES)} задокументировано.")
604
+ return 1
605
+ print(f"Паритет: все {len(CASES)} выражений задокументированы "
606
+ f"в {len(md_paths)} файлах семейства chatbot-artifacts.")
607
+ return 0
608
+
609
+
610
+ if __name__ == "__main__":
611
+ if len(sys.argv) > 1 and sys.argv[1] == "--scan":
612
+ sys.exit(scan(sys.argv[2:]))
613
+ if len(sys.argv) > 1 and sys.argv[1] == "--parity":
614
+ sys.exit(parity(*sys.argv[2:]))
615
+ sys.exit(main())