Лана: РАЗВЕДКА. Ни одного .py не изменено и не создано — проверять по git status
(мои артефакты замера лежат вне репозитория, в C:\temp\recon_wave2\).
Боевые базы открывались строго mode=ro.
Замеры: 2026-07-29. Ветка на момент замера содержала 4 неотслеживаемых файла от
параллельных агентов (_measure_msg_ids.py, _measure_msg_ids2.py,
_measure_savdel.py, _probe_schema.py) — это артефакты текущей сессии, не долг.
Итого по дереву: 106 .py = 41 продакшн/инструмент + 65 test_*.
Метод: ast.parse по всем 106 файлам, построен граф локальных импортов
(C:\temp\recon_wave2\imports.py). Модуль считается несшитым, если его имя не
встречается ни в одном import/from ... import любого другого файла.
| Файл | строк | тест | кто вызывает в боте | вердикт |
|---|---|---|---|---|
web_lookup.py |
458 | test_web_lookup.py (326 строк) |
никто | ещё не подключено |
search_engine_safe.py |
26 | нет | никто | ещё не подключено |
search_engine.py |
68 | нет | никто | брошено (заменено на _safe) |
blocking_tools.web_search_async (blocking_tools.py:713) |
— | test_fix_subprocess.py:431 |
никто | ещё не подключено |
Замер: rg 'perform_search|web_search_async|search_engine|web_lookup' *.py даёт
17 попаданий, из них 0 — вызов из assistant.py, main.py или summarizer.py.
В assistant.py и main.py слов web_search, perform_search, DDGS, tavily,
grounding — ноль (проверено rg по обоим файлам).
summarizer.py:9 содержит комментарий, что импорт search_engine_safe был
намеренно удалён, потому что «не вызывался ни разу».
То есть цепочка веб-поиска существует в четырёх слоях (подпроцесс → обёртка →
ранжирование источников → тесты, суммарно ~550 строк + 326 строк проверок), и
у врача этой функции нет. web_lookup.py создан 2026-07-29 07:25, то есть
в ЭТУ сессию: другой агент построил слой качества над поиском, который никем не
вызывается. Его же docstring измеряет цену: из 12 784 фактов ссылку содержат 4
(0.03%), архив кончается 2026-02-19 — на сегодня 160 дней слепоты.
Последствие для врача: на вопрос про препарат/материал, появившийся после февраля, бот отвечает по памяти чата без источника, хотя код, умеющий дать проверяемую ссылку, лежит в репозитории и покрыт тестами.
Чем доказать, что исправлено: поведенческий тест, который подсовывает
assistant-у фиктивный провайдер поиска и проверяет, что (а) ответ врачу
содержит ссылку из выдачи, (б) реклама клиник отсеяна, (в) при отказе провайдера
в журнал ушла запись warning. Проверка «в assistant.py есть строка
web_lookup» НЕ считается.
| Файл | строк | что делает | следы запуска | вердикт |
|---|---|---|---|---|
reclass.py |
195 | переклассифицирует 12 784 факта через Gemma-3-27b, ALTER TABLE ... ADD COLUMN is_reclassified |
колонка is_reclassified в боевой базе ЕСТЬ, значение 1 у 12784 из 12784 |
запускался, прошёл по всей базе |
deppd.py |
158 | выкачивает историю чата в stomat_archive.db |
dumper_session.session 319 488 Б, таблица archive_messages 117 847 строк |
запускался |
visionproc.py |
225 | прогоняет медиа архива через vision, пишет vision_description |
vision_session.session 282 624 Б, uploaded_media/ 9 651 файл |
запускался |
savdel.py |
132 | экспорт вики в wiki_final_review/ по 52 категориям |
каталог существует, 52 файла | запускался |
filemake.py |
61 | экспорт вики в wiki_review/ |
каталог существует, 280 файлов | запускался |
prompter.py |
97 | wiki_final_review/ → TITAN_PROMPTS_v7/ |
каталог существует, 52 файла | запускался |
checker.py |
92 | считает факты по категориям, держит CAT_NAMES (53 кода) |
следов нет (только печать) | запускался, вреда нет |
pathcheck.py |
62 | проверяет 50 случайных путей медиа в архиве | только печать | вспомогательный |
inspector.py, id.py, delme.py |
55/29/21 | печать списка чатов и их ID | только печать | разовые, брошены |
debugdist.py |
51 | тестовый прогон distiller на 50 сообщениях |
distiller.log 414 874 Б |
запускался |
| Файл | строк | требует | состояние |
|---|---|---|---|
videosi.py |
144 | INPUT_FILE = "videos.txt" |
файла нет в репозитории |
import_videos.py |
127 | INPUT_FILE = "videos.txt" |
файла нет в репозитории |
Оба пишут INSERT INTO distilled_facts в БОЕВУЮ вики. videosi.py вставляет с
is_reclassified = 1, import_videos.py — без этой колонки вовсе (то есть был
написан до reclass.py). Дубль: два файла делают одно и то же из одного и того же
входа. Ни один не покрыт тестом, ни один не описан в README.md.
Последствие: если кто-то положит videos.txt и запустит «тот, который под рукой»,
получатся факты с разной разметкой is_reclassified — и половина новых видео-кейсов
станет невидимой для любого запроса, который фильтрует по этой колонке.
Чем доказать: один входной путь вместо двух + тест, который прогоняет импорт видео на временной копии базы и проверяет, что вставленная строка находится тем же запросом, каким бот достаёт остальные факты.
benchmark.py (61, raise ImportError при импорте), delist.py (13, то же),
patch_assistant.py / patch_assistant_v2.py (134/129, оба защищены),
config.example.py (19, шаблон), run_all_tests.py (170, раннер).
Здесь всё в порядке — сторожит test_import_safety.py.
Дефект сторожа (P2): его список PRODUCTION (test_import_safety.py:42) содержит
12 модулей, а фактическое замыкание импортов main.py — 13: в списке нет
tg_safety.py (603 строки, импортируется из assistant.py). Значит проверка [3]
«боевые модули не ходят в сеть при импорте» на tg_safety.py не выполняется
вообще. Проверка [1] ловит только open(..., 'w') с литералом .py; модуль,
который на уровне модуля открывает БД или пишет .json/.db, пройдёт молча.
Чем доказать: PRODUCTION вычисляется из графа импортов main.py, а не
перечисляется руками; тест падает, если в замыкании появился модуль, которого нет
в списке.
Метод: по каждому скрипту извлечены его INSERT/UPDATE/ALTER/CREATE (ast + re),
затем в боевых базах (mode=ro) проверено наличие таблиц/колонок, а на диске —
наличие каталогов и файлов, которые скрипт пишет.
Живое состояние баз:
| База | размер | таблицы | строк |
|---|---|---|---|
stomat_wiki.db |
9 158 656 Б | distilled_facts |
12 784 |
stomat_archive.db |
47 587 328 Б | archive_messages |
117 847 |
stomat_bot.db |
12 054 528 Б | messages |
32 883 (устаревший снимок, выводов не делаю) |
Ни один скрипт не создал таблицы, которой нет. То есть «генератор, который
никогда не запускался» в чистом виде здесь только один класс — п. 1.3
(videosi.py, import_videos.py): их вход videos.txt отсутствует, а обе колонки,
которые они пишут, в базе есть, так что по следам в схеме отличить их запуск от
незапуска нельзя. Прямое доказательство незапуска import_videos.py: он вставляет
БЕЗ is_reclassified, а в базе все 12 784 строки имеют is_reclassified = 1 —
значит после reclass.py он не работал ни разу.
P2, отдельно: distiller.py:692 создаёт CREATE INDEX IF NOT EXISTS idx_cat ON distilled_facts(category_code), но в боевой stomat_wiki.db индексов нет вообще
(sqlite_master содержит только distilled_facts и sqlite_sequence). То есть
дистиллятор со своей текущей схемой на этой базе не запускался — или запускался
до появления этой строки. Поиск по 12 784 фактам идёт полным сканом.
Чем доказать: тест поднимает пустую копию схемы, вызывает функцию инициализации
дистиллятора и проверяет PRAGMA index_list(distilled_facts) — то есть факт, а не
наличие строки в исходнике.
Замер п.1-2 сделан в 09:21. Пересчёт в 09:58 тем же imports.py:
| Замер | 09:21 | 09:58 |
|---|---|---|
| всего .py в корне | 106 | 128 |
из них test_* |
65 | 68 |
артефакты сессии (_measure_*, _probe_*, _smoke_*, _bak_*) |
4 | 25 |
web_lookup.py строк |
458 | 499 |
videosi.py строк |
144 | 265 |
reclass.py строк |
195 | 331 |
Два уточнения по существу, а не по счёту:
- Веб-поиск частично сшили, но не до врача.
blocking_tools.py:759теперь делаетimport web_lookupвнутри_as_search_entryи зовётweb_lookup.parse_result, аsearch_engine_safe.py:4импортируетweb_search_async. Цепочка собрана:search_engine_safe.perform_search → blocking_tools.web_search_async → web_lookup. Ноperform_searchне вызывает никто:rg 'perform_search' --glob '*.py'даёт попадания только в самихsearch_engine.py,search_engine_safe.pyи отчётах. Вердикт п.1.1 не изменился — у врача функции нет, изменилось только качество внутренностей. search_engine.pyпротивsearch_engine_safe.py— не «старый и новый», а два разных класса отказа.search_engine.py:4делаетfrom ddgs import DDGSна уровне модуля: если пакета в окружении боевой машины нет, импорт валит вызывающего, а не деградирует.search_engine_safe.pyходит в подпроцесс и возвращает"Ошибка поиска."строкой. Подключать надо_safe, аsearch_engine.py— удалять, иначе следующий агент подключит «тот, который короче». P2. Доказательство удаления:test_import_safety.pyпадает, если в дереве есть модуль с сетевым импортом на уровне модуля (сейчас не падает — см. дефект сторожа).
P1, новое: _bak_assistant_tgdelivery.py — байт-в-байт копия assistant.py.
md5sum обоих = b17ea736fb...(md5, укорочен), 402 406 Б, создан 09:56.
Это чей-то бэкап под саботаж, но пока он лежит в корне, он попадает во все обходы
дерева: мой ast-обход насчитал в нём те же 87 обработчиков except, и
test_import_safety.py (сканирует os.listdir по *.py) тоже его увидит. Если такой
файл останется в дереве после сессии, любая следующая инвентаризация даст двойные
числа, а patch_assistant-подобный скрипт может начать патчить копию.
Чем доказать: fd '^_bak' -e py в корне даёт 0 файлов.
Метод: ast.parse по всем 128 .py (C:\temp\recon_wave2\bare.py, только чтение).
Классы считаны отдельно, потому что они лечатся разным: голый except: (ловит
KeyboardInterrupt/CancelledError/SystemExit наравне с ошибкой), except Exception: pass (тело ровно pass), немой (в теле ни одного
.debug/.info/.warning/.error/.exception/.critical/.log/print).
Замер по истории (git show <commit>:main.py | rg -c '^\s*except\s*:', только чтение):
| коммит | что это | голых except: в main.py |
|---|---|---|
dec2638 |
до сессии | 2 |
a924ea0 |
контрольная точка сессии | 2 |
50f7c64 |
П4 (планировщик) | 1 |
e786bf0 |
П3 = HEAD | 0 |
Оба сайта найдены в истории и оба закрыты:
- бывший
main.py:529(targets = config.REPORT_TARGETS/except:/targets = []) — П4 развернул его в валидаторmain.py:587-624с четырьмя раздельными записями (не прочитан,не список,пропущен: нет chat_id,все N целей битые). - бывший
main.py:1807(await event.delete()/except:/pass) — сейчасmain.py:2426-2433,except Exception as exc+logger.debugи комментарий, что голыйexcept:ловил тут остановку бота и она «выглядела как нет прав на удаление».
main.py в рабочем дереве не изменён относительно HEAD (git status), значит замер
относится к тому же тексту, что и у лида. Ставить в следующую волну задачу «убрать 2
голых except в main.py» нечего — их ноль.
prompter.py:33, функция build_titan_prompts. Разбор имени файла
2.2.1_Орто_Техника_BOPT.txt на код и тему; при отказе категория становится
X.X.X, а темой — всё имя файла.
Последствие: prompter.py — генератор промптов для дистилляции, не боевой путь; врач
этого не увидит. Но Ctrl-C во время прогона по 52 файлам не остановит скрипт, а
молча положит один промпт в категорию X.X.X, и дальше эта категория уедет в
TITAN_PROMPTS_v7/. P3 — единственный оставшийся голый обработчик, лечится
except (IndexError, ValueError) и одной строкой в журнал.
_fix_observe.md:75-76 (ЗАМЕР 2, метод тот же, 35 модулей): 242 обработчика, 87
немых, 79 немых-без-переброса. Мой пересчёт в 09:58 по 38 боевым/инструментальным
модулям (артефакты _bak_*/_smoke_*/_measure_*/_probe_* вычтены):
всего обработчиков except: 279 (было 242)
немых (ни одной записи): 104 (было 87)
немых И без переброса (ошибка исчезает совсем): 94 (было 79)
голых (except: без класса): 1 (было 2 только в main.py)
Пофайлово, «обработчиков / из них немых», было → стало:
| модуль | было | стало | немых было → стало |
|---|---|---|---|
assistant.py |
86 | 87 | 20 → 21 |
main.py |
68 | 73 | 19 → 17 |
blocking_tools.py |
16 | 19 | 16 → 15 |
media_tools.py |
8 | 13 | 8 → 9 |
runtime_guard.py |
7 | 10 | 7 → 10 |
gemini_client.py |
14 | 14 | 8 → 8 |
database.py |
6 | 6 | 1 → 0 |
reclass.py |
3 | 6 | 1 → 3 |
videosi.py |
2 | 3 | 1 → 2 |
vision.py, search_engine.py, prompter.py, run_all_tests.py |
3/3/1/3 | 3/3/1/3 | без изменений |
Читается так: сессия закрыла немоту точечно там, где её ловили тестом
(main.py -2, blocking_tools.py -1, database.py -1), и одновременно добавила
+17 немых обработчиков новым кодом (runtime_guard.py 7→10, все десять немые;
media_tools.py 8→9; reclass.py 1→3; assistant.py +1). Нетто немота выросла, а
не упала. Это и есть «набор файлов»: каждый агент чинит свой участок и приносит свою
немоту.
Оговорка по runtime_guard.py 10/10: это модуль, который настраивает журнал, и
часть его обработчиков немы вынужденно (упасть в logger во время configure_logging
нельзя). Классификатор с учётом «сохраняет ли обработчик причину» (excepts3.py)
даёт по нему: 2 со следом, 5 сохраняют причину в dump_error, 3 мёртвых. То есть
честная цифра долга по runtime_guard.py — 3, а не 10.
По дереву 144, но 119 из них — в test_*.py и артефактах, где это try: import X / except: pass шапки тестов. В боевом коде и инструментах — 24, из них на ГОРЯЧЕМ
пути (замыкание от пяти обработчиков Telethon) 13:
| файл:строка | функция | что глохнет для врача |
|---|---|---|
assistant.py:418 |
save_state |
состояние сессии врача не записалось — при перезапуске он начнёт диалог заново, и причины в журнале нет |
assistant.py:1255 |
check_and_apply_silence |
режим тишины не применился, бот может заговорить там, где велено молчать |
assistant.py:2073, :2082, :2127 |
check_and_trigger_assistant_media |
снимок врача не ушёл в разбор, ответа не будет и следа тоже |
assistant.py:2564 |
handle_interactive_case_step |
шаг интерактивного разбора клинического случая потерян |
assistant.py:2746, :2749, :3106 |
handle_private_message |
личный вопрос врача обработан не до конца |
assistant.py:3946 |
handle_group_summary |
сводка по группе не собралась |
assistant.py:5041 |
check_and_trigger_referee |
арбитр спора не вмешался |
media_tools.py:62, :161 |
_prepare_image_sync, _settle_after_kill |
снимок не подготовлен / убитый ffmpeg не дождался — верхний уровень видит «нет файла» без причины |
Остальные 11 — gemini_client.py:190/703/746/859/873, runtime_guard.py:113/353/355,
distiller.py:343, main.py:12, savdel.py:15. Из них main.py:12 и savdel.py:15
безобидны (reconfigure кодировки и опциональный импорт), остальные — реальный долг.
Приоритет: P1 на девять сайтов assistant.py в handle_private_message /
check_and_trigger_assistant_media / handle_interactive_case_step — это ровно те
три сценария, где врач ждёт ответ и не получает ни ответа, ни строки в журнале.
P2 на media_tools.py. P3 на остальное.
Чем доказать, что исправлено: поведенческий тест на каждый сайт — подсунуть в
зависимость исключение и проверить, что (а) в caplog/перехваченном журнале есть
запись с типом исключения, (б) вызывающий получил отрицательный результат, а не
None, который он трактует как «нечего делать». Проверка «в файле нет строки
except Exception:\n pass» НЕ считается: она пройдёт, если pass заменить на
return None, а немота останется. В дереве таких «широкий except + пустое тело, но
не ровно pass» — ещё 11 сверх 24 (35 - 24).
Замер: rg -i 'todo|fixme|hack|xxx' --glob '*.py' по всем 128 файлам даёт 4
попадания, и ни одно не является пометкой долга:
distiller.py:839,847,871,872— имя переменнойtotal_todo(счётчик кандидатов сита).README.md:83—REPORT_CHAT_ID=-100xxxxxxxxxx, шаблон в примере.env._recon_observe.md— 4 попадания внутри моих же отчётов.
Регистрозависимый rg 'TODO|FIXME|HACK|XXX' --glob '*.py' даёт 0.
Русские пометки долга тоже искал: заглушк|костыл|не реализован|недоделан|потом переписать|надо переписать|в будущем|пока не|временно|позже — 30 попаданий в 12
боевых файлах, из них 29 ложные:
заглушк— стоматологический термин (формирователь десны),dental_vocab.py:41,assistant.py:4563. Тот самый класс, что «рот» внутри «оборот».позже/временно— текст сообщений ВРАЧУ («Попробуйте позже», «База временно недоступна»), 8 сайтов вassistant.py.одновременно— попадает на подстрокувременно, 6 сайтов.
Единственная настоящая пометка: assistant.py:3723 — docstring
check_bot_mention_trigger говорит «shadow mode пока не промотировано», а
assistant.py:3742 уже содержит BOT_MENTION_SHADOW_MODE = False # Выкачено в боевой. Документация в коде противоречит коду на 19 строк ниже. P3, но это
именно тот дефект, который заставит следующего агента искать несуществующий
непромотированный режим.
Вывод по пункту: список долга в этом репозитории не ведётся в коде вообще. Долг
живёт в _fix_*.md и _recon_*.md (19 файлов, 320 КБ), которые не связаны с
кодом ничем: ни один .md не упомянут ни в одном .py, и ни один .py не
ссылается на номер пункта отчёта. Отсюда прямое следствие для лида: как только
отчёты этой сессии устареют, ЕДИНСТВЕННЫМ носителем знания о долге останется
git log. Голых except: в main.py уже ноль, а задание на волну 2 всё ещё
просило их убрать — ровно эта поломка связи.
P2. Чем доказать: долг, который волна оставляет незакрытым, помечается в КОДЕ
(# ДОЛГ: рядом со сайтом) и тест-инвентарь считает эти пометки, чтобы число в
отчёте и число в дереве нельзя было разъехать.
Четвёртая копия — assistant.WIKI_TREE (assistant.py:4227), 11 разделов,
50 подтем, 53 кода. Это та копия, которую ВИДИТ ВРАЧ: подписи кнопок
энциклопедии. Всего копий не четыре, а шесть:
| копия | где | кодов | роль |
|---|---|---|---|
assistant.WIKI_TREE |
assistant.py:4227 |
53 | кнопки энциклопедии, видит врач |
checker.CAT_NAMES |
checker.py |
53 | отчёт «сколько фактов по разделам» |
savdel.CAT_MAP |
savdel.py |
55 | имена файлов экспорта wiki_final_review/ |
distiller.KNOWLEDGE_TREE |
distiller.py:~220 |
55 | текст в промпте классификатора |
reclass.KNOWLEDGE_TREE |
reclass.py:~58 |
52 | текст в промпте переклассификации |
videosi.KNOWLEDGE_TREE |
videosi.py |
0 | переменная переименована/удалена другим агентом в 09:39 |
Объединение всех копий — 57 кодов; все шесть знают 52. Расходятся ровно 5:
| код | живых фактов | кто знает | последствие |
|---|---|---|---|
6.1.2 |
82 | ТОЛЬКО assistant |
врач открывает кнопку «Оптика и оборудование» и статьи видит; checker.py их не считает, savdel.py при экспорте не знает, в какой файл их класть, дистиллятор в промпте этот код не предлагает — значит новых фактов под 6.1.2 больше не появится, раздел заморожен на 82 |
10.1 |
5 | ТОЛЬКО checker |
двухчастный код в карте отчёта, которого нет ни в одном дереве; у врача кнопки нет |
8.1.1, 9.1.1, 10.1.1 |
0 каждый | savdel + distiller |
дистиллятор предлагает LLM три раздела, под которые за 12 784 факта не попало ни одного; savdel держит для них имена файлов |
По СМЫСЛУ на 52 общих кодах копии НЕ расходятся: 0 кодов из 52. Первый прогон
дал «49 из 52», но это был дефект моего замера — _ в Python \w словарный символ,
и 'Эндо_Доступ_МБ2' оставалось одним токеном, поэтому пересечение слов не находилось
никогда. После разбиения по всему, кроме букв и цифр, и словаря сокращений
(эндо→эндодонтия, орто→ортопедия, цифра→цифровая) расхождений без общего слова
не осталось. Что действительно различается — стиль записи ('Ортопедия: Виниры' /
'Орто_Виниры' / '👑 Ортопедия / 💎 Виниры') и 11 кодов, где различается
уточняющее слово: 2.1.4 (Вкладки у checker против Микропротезирование у двух
других), 3.2.3 (Мультиюниты против Компоненты), 6.1.1 (Микроскопы против
Оптика), 3.3.1/3.3.2 (checker относит к «Пластика», врач — к «Пародонтология» и
«Хирургия»).
Отдельно: у врача 3 кода склеены в одну кнопку. surg_impl = 3.2.1 + 3.2.2 + 3.2.3 (планирование + системы + мультиюниты), com_optic = 6.1.1 + 6.1.2. То есть
50 кнопок против 53 кодов. Это не дефект, но объясняет, почему число копий разъехалось.
Первая гипотеза была тревожной и оказалась ЛОЖНОЙ. В боевой stomat_wiki.db
6 711 различных значений category_code при 53 кодах в карте, и только 16 из них
совпадают с кодом карты буквально. Похоже на «12 670 фактов бот не найдёт». Замер
показал обратное: category_code хранит список кодов через запятую
('1.2.6, 1.2.2, 1.2.1'), а бот ищет category_code LIKE '%код%'
(assistant.py:4412, :4490), что список разбирает верно.
Проверка на ложные попадания подстроки (тот самый класс «рот в обороте», но на
кодах) — C:\temp\recon_wave2\wikimatch.py, сравнение LIKE '%X.Y.Z%' против
честного вхождения в разобранный список:
53 кода: ложных попаданий 0, кодов с нулевой выдачей 0
пустых подтем 0 из 50
фактов достижимо через кнопки энциклопедии: 12 733 из 12 784 (99.6%)
Ложных нет потому, что все старшие разряды однозначные и код всегда трёхчастный —
но это свойство данных, а не кода: появится код 11.2.1, и LIKE '%1.2.1%'
начнёт отдавать его врачу в разделе «Адгезия и IDS». P3, с оговоркой: сейчас не
болит, замер это доказал.
Недостижимы кнопками 51 факт (0.4%) — под кодами, которых нет ни в одной копии:
'1.1', '2.0.0', '7.2.2', '2.1', '1.1.0', '2.5.1', '2.6', '6.3' и ещё
48 обрубков. Это следы ранних прогонов дистиллятора с другой разметкой.
| дубль | замер | приоритет |
|---|---|---|
_bak_assistant_tgdelivery.py = assistant.py |
md5 совпадает, 402 406 Б | P1 (артефакт сессии, убрать) |
search_engine.py / search_engine_safe.py |
68 и 26 строк, обе дают perform_search, разный класс отказа |
P2 |
videosi.py / import_videos.py |
оба пишут INSERT INTO distilled_facts из videos.txt |
P2 |
patch_assistant.py / patch_assistant_v2.py |
134 и 129 строк, оба патчат assistant.py |
P3 |
filemake.py / savdel.py / prompter.py |
три экспортёра вики в три каталога (wiki_review/ 280 файлов, wiki_final_review/ 52, TITAN_PROMPTS_v7/ 52) |
P3 |
KNOWLEDGE_TREE как ТЕКСТ в промпте |
distiller.py и reclass.py — две почти одинаковые простыни на 52-55 строк внутри строковых литералов |
P2 |
Чем доказать, что таксономия сведена в одно место: тест, который берёт
единственный источник (например taxonomy.py с одним словарём) и проверяет
поведением, что (а) каждый код, под которым в боевой базе есть хотя бы один факт,
имеет кнопку у врача, (б) каждый код, который дистиллятор предлагает LLM, имеет имя
в отчёте checker, (в) кнопок ровно столько, сколько разделов в источнике. Сейчас
такой тест не существует: checker.py, savdel.py, reclass.py, videosi.py,
import_videos.py, filemake.py, prompter.py — ни один не покрыт ни одним
тестом (см. п.7).
Сначала честно: test_budget_nesting.py существует (211 строк) и я его прогнал —
29 проверок, 0 провалов. Он закрывает 8 групп. Ниже таблица того, что он
сопоставляет, и отдельно то, что НЕ сопоставляет никто.
| путь | родительский дедлайн | дочерние | влезает |
|---|---|---|---|
| сводка/дайджест | main.SUMMARY_STALE_SECONDS 2700 |
генерация 2100 + Telegraph 90 + отправка 90 + закреп | да, запас 600 |
| подпроцесс (любой) | timeout + _SUBPROCESS_STARTUP_SLACK_SECONDS 10 |
бюджет ребёнка | да |
| сторож процесса | WATCHDOG_STALE_SECONDS 300 |
сердцебиение 30 (10 ударов) | да |
| подъём + догон | SYNC_HISTORY_TIMEOUT_SECONDS 900 |
подключение 120, автор 10 | да |
| разбор снимка | MEDIA_ANALYSIS_TIMEOUT_SECONDS 258 |
скачивание 120, кадр 60, троттлинг зрения 3×3=9 | да |
| длина сообщения | Telegram 4096 | наш предел 4000, сводка 4000 | да |
| расшифровка (группа) | 60 + 10 = 70 | 7 ключей × 7.0 с = 49 | да |
main.py:1181 VOICE_TRANSCRIBE_TIMEOUT_SECONDS = 60
blocking_tools.py:224 _SUBPROCESS_STARTUP_SLACK_SECONDS = 10.0
blocking_tools.py:482 deadline = timeout + slack -> родитель убивает дерево на 70 с
gemini_client.py:737 subprocess.run(cmd, ..., timeout=120) <-- ребёнок ребёнка
gemini_client.py:666 subprocess.run([path,'-version'], timeout=30) x 2 кандидата = до 60 с
Цепочка: main.transcribe_group_voice(60) → подпроцесс (дедлайн 70) →
transcribe_audio_bytes_or_file → convert_to_wav → _ffmpeg_to с собственным
таймаутом 120 с, а до него ffmpeg_binary() с до 60 с на перебор кандидатов.
Внутренний потолок 180 с против родительских 70. Ровно тот же класс, что три случая
из docstring теста, и ни одна из 29 проверок эти два числа не сравнивает.
Последствие для врача: длинная диктовка или пересланная аудиозапись (путь
перекодирования включается при размере > 24 МБ или нештатном контейнере) не будет
расшифрована никогда — родитель убьёт дерево на 70-й секунде посреди конвертации.
Комментарий на gemini_client.py:735-737 это ПРЯМО описывает («родитель убивает
дерево процессов по своему дедлайну и оставляет недописанный файл»), то есть автор
знал про убийство и всё равно оставил 120: число не согласовали.
Чем доказать: проверка сравнивает 120 (или откуда бы он ни брался) с
VOICE_TRANSCRIBE_TIMEOUT_SECONDS + _SUBPROCESS_STARTUP_SLACK_SECONDS и падает при
>=. Поведенческая версия: подсунуть фиктивный «ffmpeg», который спит дольше
родительского дедлайна, и проверить, что расшифровка вернула отказ С ПРИЧИНОЙ, а не
была убита снаружи.
gemini_client.py:785 packed = _ffmpeg_to(file_path, base + "_converted.ogg", ...) # путь >24 МБ
gemini_client.py:791 wav = _ffmpeg_to(file_path, base + "_converted.wav", ...) # крайний случай
blocking_tools.py:803 wav_path = base + "_converted.wav" # убирается ТОЛЬКО это
_remove_converted_wav (blocking_tools.py:790) — уборщик за убитым ребёнком, и его
собственный комментарий признаёт дублирование: «Имя задаётся в
gemini_client.convert_to_wav; правило продублировано здесь осознанно». Копия уже
разъехалась: .ogg не убирает никто. И разъехалась она именно в той ветке, где
убийство наиболее вероятно — большой файл, дорогая конвертация, п.6.2.
Оговорка, честно: частоту я измерить не смог. В боевом архиве 117 847 реплик и
ни одной с media_type = voice/audio (photo 14 754, video 804, file 444,
без медиа 101 845), temp_media/ на этой машине пуст, uploaded_media/ — 9 651 файл
и все .jpg (макс 0.2 МБ). Дефект доказан чтением имён, а не наблюдённой утечкой.
Чем доказать: имя конвертата возвращается/строится ОДНОЙ функцией, которую зовут и создатель, и уборщик; тест создаёт оба файла, зовёт уборщик и проверяет, что не осталось ни одного.
Живой замер (C:\temp\recon_wave2\ffprobe.py, минимум из 3 прогонов на кандидата):
кандидат ffmpeg_binary() |
время | rc | годен | размер |
|---|---|---|---|---|
STOMCHAT_FFMPEG_PATH |
— | не задан | — | — |
PATH → ...Python313\Scripts\ffmpeg.EXE |
79.8 мс | 1 | нет | 108 390 Б (шим) |
imageio_ffmpeg |
— | пакета нет | — | — |
static_ffmpeg |
0.3 мс | FileNotFoundError |
нет | bin/ffmpeg.exe отсутствует |
C:\Users\Admin\Desktop\stomchat\ffmpeg.exe |
59.7 мс | 0 | ДА | 99 264 000 Б |
То есть ffmpeg_binary() перебирает 2 живых кандидата, оба негодны, возвращает None
и пишет предупреждение — при рабочем 99-мегабайтном ffmpeg.exe, который лежит в
корне того же репозитория и в список кандидатов не входит. Каталог скрипта код
знает и использует (assistant.py:25 SCRIPT_DIR), просто не здесь.
Последствие для врача на этой машине: нештатный контейнер и любое аудио > 24 МБ не расшифровываются, хотя бинарь под рукой. Плюс «ffmpeg сломан» уходит в ловушки проекта как свойство машины, хотя это свойство списка кандидатов.
Чем доказать: тест вызывает ffmpeg_binary() и проверяет, что вернулся путь,
который реально печатает ffmpeg version (а не «строка непуста»); при отсутствии
любого годного бинаря — громкий SKIP, как требует правило по ffmpeg.
| смысл | как константа | как литерал | риск |
|---|---|---|---|
| бюджет расшифровки | main.VOICE_TRANSCRIBE_TIMEOUT_SECONDS = 60 (main.py:1299) |
timeout=60 в ЛС (assistant.py:2765) |
тест [8] проверяет только константу; поднимут её ради п.6.2 — ЛС останется на 60 |
| извлечение кадра | main.MEDIA_FRAME_TIMEOUT_SECONDS = 60 |
timeout=60 (assistant.py:3430) |
то же |
| генерация ответа LLM | константы НЕТ | timeout=90 — 9 сайтов в assistant.py (:1972, 2276, 2590, 3104, 3290, 3666, 3965, 4071, 4184), плюс 20, 25, 45, 60 в других |
менять придётся в 9 местах |
Всего литеральных timeout=<число> в вызовах боевых модулей — 65, из них 32 в
assistant.py. Модульных констант бюджета — 66, и 44 из них не упомянуты в
test_budget_nesting.py ни разу.
Отдельно про 90-секундные генерации: родительского дедлайна у них НЕТ вовсе —
main.py запускает триггеры через runtime_guard.create_task(...) без wait_for.
Это не нарушение вложенности (нарушать нечего), но и потолка нет: timeout=30 в
main.py:1876 и соседних оборачивает только записи в базу.
search_engine_safe.perform_search (:14 и :18) вызывает
web_search_async(..., timeout=45) дважды подряд — второй раз укороченным
запросом. Каждый вызов внутри превращается в _run_json_tool с дедлайном
45 + 10 = 55 с. Худший случай 110 с, и родителя у этого пути сейчас нет,
потому что perform_search не вызывает никто (п.1). Если следующая волна подключит
поиск в обработчик сообщения, это станет шестым случаем класса в момент
подключения.
Чем доказать заранее: при сшивке поиска бюджет передаётся сверху одним числом на
всю операцию (как сделано в tg_safety.guard, где timeout — полный бюджет, а не
бюджет попытки), и тест проверяет, что две попытки в сумме не превышают его.
Метод, оговорка сразу: «покрыт» здесь = имя функции упоминается хотя бы в одном
test_*.py. Это ВЕРХНЯЯ оценка покрытия: упоминание не значит, что поведение
проверено, а короткое имя (main, say, check_paths) даёт ложное «покрыто».
Нижней оценки без запуска покрытия не построить, и я её не строил.
Из 39 боевых/инструментальных модулей 21 не импортируется ни одним test_*.py:
benchmark.py, checker.py, debugdist.py, delist.py, delme.py, deppd.py,
filemake.py, id.py, import_videos.py, inspector.py, patch_assistant.py,
patch_assistant_v2.py, pathcheck.py, prompter.py, reclass.py, savdel.py,
search_engine.py, search_engine_safe.py, videosi.py, visionproc.py,
run_all_tests.py.
Из них пишут в БОЕВУЮ базу или в файлы: reclass.py (ALTER TABLE +
UPDATE 12 784 строк), videosi.py и import_videos.py (INSERT INTO distilled_facts), deppd.py (пишет stomat_archive.db), visionproc.py (пишет
vision_description), savdel.py/filemake.py/prompter.py (пишут каталоги).
Семь скриптов с правом записи в боевые данные и ноль тестов на всех семерых.
P1 по совокупности: любой из них, запущенный «чтобы посмотреть», меняет корпус,
на котором бот отвечает врачу, и откатить это нечем — бэкап вики один,
backup_wiki_18_0140.db от 2026-02-18.
Модуль импортируется — значит по п.7.1 «покрыт». По функциям картина хуже:
| модуль | публичных | не названы | самые дорогие из неназванных |
|---|---|---|---|
main.py |
46 | 10 | transcribe_group_voice, media_analysis_worker, stop_media_analysis_workers, extract_first_frame, strip_bot_mention |
distiller.py |
18 | 7 | init_wiki_db, fetch_batch, mark_processed, content_hash, clean_json_string |
assistant.py |
55 | 5 | analyze_dispute_need, check_referee_triage, generate_user_portrait, calculate_context_length_guidelines |
html_safe.py |
6 | 4 | balance_html, unclosed_tags, safe_cut_index, html_to_plain |
database.py |
35 | 4 | get_messages_for_period, get_reply_chain_texts, get_unsummarized_count, get_last_msg_id |
dental_vocab.py |
4 | 1 | is_non_clinical_word |
summarizer.py |
7 | 1 | get_russian_date |
tg_safety.py |
10 | 1 | default_cooldown |
Три из них стоит поднять в P1, потому что они лежат ровно на горячем пути и уже содержат немые обработчики из п.3:
main.transcribe_group_voice— вход расшифровки голосового в группе, немой обработчикmain.py:1321. При этомtest_voice_offline.py(626 строк) иtest_voice_pipeline.py(504 строки) существуют и импортируютmain: они проверяют окрестности, а саму функцию не зовут. Врач говорит голосом — проверки на самой точке входа нет.main.media_analysis_worker— рабочий разбора снимков, немой обработчикmain.py:1119. Снимок врача входит именно здесь.html_safe.balance_html/unclosed_tags/safe_cut_index— ровно та механика, из-за которой Telegram отклоняет СООБЩЕНИЕ ЦЕЛИКОМ при битой разметке. Из 6 публичных функций модуля тесты называют 2.
И одна для п.2 отчёта: distiller.init_wiki_db не назван ни одним тестом — а
именно он создаёт CREATE INDEX idx_cat, которого в боевой базе нет.
Чем доказать, что покрытие появилось: для каждой из перечисленных функций тест
вызывает её и проверяет НАБЛЮДАЕМЫЙ результат — возвращённое значение, запись в
журнале, состояние базы. Список «не названных» пересчитывается тем же скриптом
(C:\temp\recon_wave2\coverage.py), и число должно падать; проверка «файл
test_X.py существует» не считается.
Пять тестов не импортируют вообще ничего из проекта и проверяют текст исходников:
test_commands_surface.py (186 строк), test_config_contract.py (489),
test_isolation.py (162), test_passive_gate.py (134), test_ping_policy.py (118),
test_state_atomicity.py (149), test_validator_coverage.py (159),
test_validator_policy.py (185). Это 8 файлов, 1582 строки проверок, ноль
импортов боевого кода. Часть из них так задумана (контракт конфигурации читает
config.example.py как текст), но по правилу проекта «проверка «в исходнике есть
строка» не считается» — их нужно перечитать отдельной волной, иначе они дают
ложное ощущение покрытия. То же относится к 4 текстовым проверкам внутри
test_budget_nesting.py (:64-68, :122-125, :182-190): они сравнивают строки
исходника, а не поведение, и переживут любую переделку с сохранением текста.
Прочитаны целиком _recon_observe.md (320 строк), _recon_telegram.md (306),
_recon_distill.md (250). По каждому пункту — проверка на ЖИВОМ коде и данных, а
не по отчёту о правке.
| пункт | состояние | доказательство |
|---|---|---|
Н1 строка в журнал перед os._exit(78) |
СДЕЛАНО | runtime_guard.py:380 _log_watchdog_exit(age, dump_error), :51 WATCHDOG_LOG_GRACE_SECONDS, выход вооружён ДО записи |
Н2 релей stderr в media_tools |
СДЕЛАНО | media_tools.py:103 _drain_stderr тянет stderr по ходу в кольцо, хвост уходит одной записью |
| Н3 тип и трейсбек у падения ребёнка | СДЕЛАНО | blocking_tools.py:966 logger.exception("дочерний процесс упал action=%s"), :967 _describe_exception(exc) |
Н4 %s от исключения — конкретный сайт |
СДЕЛАНО | main.py:2686 печатает type(exc).__name__ и exc |
| Н4 как КЛАСС (заявлено 109 сайтов) | НЕ СДЕЛАНО, стало хуже | мой пересчёт: 115 вызовов печатают только str(исключения); assistant.py 62, main.py 29 |
Н5.1 отметка медиа (main.py вечный круг) |
СДЕЛАНО | коммит П3 + test_media_loop_guard.py |
Н5.2 ALTER TABLE ... OperationalError: pass |
СДЕЛАНО | database.py:295-299, молчит только на duplicate column |
Н5.3 голый except: у REPORT_TARGETS |
СДЕЛАНО | П4, main.py:587-624, четыре раздельные записи |
| Н5.4 писатели дампа | СДЕЛАНО | причина отказа записи уезжает в ту же строку журнала (runtime_guard.py:379) |
Н5.5 семь точек «отвечать или молчать» с except Exception: pass |
НЕ СДЕЛАНО | все на месте: assistant.py:1255 (check_and_apply_silence), :2073, :2082, :2127 (check_and_trigger_assistant_media), :5041 (check_and_trigger_referee), :5516, :5578 (check_and_send_group_activity_pings) |
| п.4.1 сканирование доставки навсегда запрещено боту | НЕ СДЕЛАНО | summarizer.py:174-176 по-прежнему logger.warning + return None |
| п.4.2 гео-блокировка считается временной ошибкой | НЕ СДЕЛАНО | gemini_client.py:88: "failed_precondition" стоит в списке retry_markers |
| п.4.3 Groq 413 на промпте дайджеста | частично | gemini_client.py:647 признаёт 413 в комментарии; отдельного короткого замыкания нет |
п.6 усечь bot.log |
НЕ СДЕЛАНО | bot.log = 12 251 строка, и в нём по-прежнему 798 строк тестовой выдумки (падение до event.answer(), файл занят другим процессом) |
Два из «не сделано» я довёл до последствия, которого в прежнем отчёте нет:
п.4.1 хуже, чем описано. P1. _find_recent_matching_message (summarizer.py:164)
вызывается из _send_message_once ДВА раза: :189 как защита от дубля и :207
как восстановление после таймаута отправки. В main.py:711 в сводку передаётся
bot_client — аккаунт бота, для которого client.get_messages(chat, limit=N)
запрещён навсегда (44 записи в журнале). Значит после ОДНОГО таймаута отправки
дайджеста восстановление не срабатывает, raise уходит наверх, и дайджест считается
недоставленным — при том что Telegram мог его принять. Врачи получают выпуск дважды
либо не получают вовсе, а в журнале это одна строка WARNING, похожая на сетевую
икоту. Рядом, в main.py:886, живёт пользовательский клиент client, которому этот
метод разрешён.
Чем доказать: тест подсовывает _send_message_once клиент, чей get_messages
бросает "API access for bot users is restricted", и проверяет, что (а) в журнале
запись уровня ERROR со словом «навсегда»/«не поддерживается», а не WARNING, (б)
защита от дубля явно объявлена неработающей, а не тихо вернула None.
п.4.2 названо построчно. P1. gemini_client.py:83-93, _is_retryable_gemini_error:
retry_markers содержит "failed_precondition". Живой журнал показал 45 записей
400 FAILED_PRECONDITION ... User location is not supported for the API use по всем
ключам — это постоянный отказ, а классификатор объявляет его временным, поэтому
каскад честно тратит 12 попыток × 4 ключа = до 48 обречённых запросов. Внутри
дайджеста (бюджет 2100 с) это «просто дорого»; внутри ответа врачу (литеральные 90 с
из п.6.5) это означает, что врач ждёт полторы минуты и не получает ничего.
Чем доказать: тест подаёт в классификатор текст живой ошибки и требует
False, плюс поведенческая проверка, что каскад на такой ошибке ходит к провайдеру
ОДИН раз, а не 48.
| пункт | состояние | доказательство |
|---|---|---|
| Н1 один хелпер отправки вместо прямых вызовов | ПОСТРОЕН И НЕ ПРИНЯТ | см. ниже |
| Н2 FloodWait не должен считаться отказом пользователя | СДЕЛАНО | assistant.py:5543 и :5741 — tg_safety.classify(err) == KIND_FLOOD, счётчик не растёт |
| Н3 дубль разрешения сущностей | НЕ СДЕЛАНО | assistant.py:1268 (check_and_apply_silence) и assistant.py:1548 (check_and_trigger_assistant) — тот же чат, тот же reply_to_msg_id, два сетевых get_messages, результат первого выбрасывается |
| Н4 логгер telethon заглушён до ERROR | СДЕЛАНО | runtime_guard.py:140 getLogger("telethon.client.users").setLevel(INFO) |
Н5 get_me() на горячем пути медиа |
СДЕЛАНО | assistant.py:2083-2084: личность берётся из BOT_ID/BOT_USERNAME |
Главная находка волны 2 — это Н1. P1. Слой границ написан и покрыт тестом:
tg_safety.py 603 строки, test_tg_safety.py 869 строк. Замер принятия
(AST, C:\temp\recon_wave2\adoption.py):
| файл | вызовов Telegram | в границах | без границ | доля прикрытых |
|---|---|---|---|---|
summarizer.py |
4 | 4 | 0 | 100 % |
main.py |
25 | 4 | 21 | 16 % |
assistant.py |
105 | 2 (плюс 5 через tg_safety.edit_message) |
103 | 1.9 % (6.4 % с учётом tg_safety) |
Из 110 обращений assistant.py к Telegram через новый слой идут пять, и все
пять — edit_message в викторине и одном хелпере (:3931, 3968, 3990, 4011, 4637).
69 send_message, 16 answer, 8 delete_messages, 4 get_messages — по-прежнему
прямые, без таймаута и без разбора FloodWait. Сценарий из _recon_telegram.md
(врач жмёт /quiz, telethon спит до 600 с внутри одной строки) закрыт ровно в тех
пяти местах, которых он не касался.
Это и есть «набор файлов, а не система» в чистом виде: инструмент качества
существует, протестирован, и НЕ ПОДКЛЮЧЁН — тот же диагноз, что у web_lookup.py
(п.1.1). Два самых больших вложения сессии оба не дошли до врача.
Чем доказать: тест считает по AST число обращений к Telegram в assistant.py,
идущих МИМО tg_safety, и падает, если оно больше согласованного потолка (сейчас
103 → цель 0). Плюс поведенческая проверка: мок клиента, который спит дольше
бюджета, и утверждение, что вызывающий получил отказ за отведённое время и запись в
журнале, а не завис.
| пункт | код | данные |
|---|---|---|
F1/F3 нормализация source_ids |
СДЕЛАНО, distiller.py:383 normalize_source_ids |
НЕ СДЕЛАНО: в боевой вики по-прежнему 353 факта, у которых ВСЕ токены нечисловые, 2 073 битых токена, восстановимо 1 985 id — цифра в цифру как в разведке |
F2 is_processed_for_wiki только при непустом результате |
СДЕЛАНО | очередь по-прежнему: 117 403 помечено, 444 нет, и все 444 — медиа без vision, то есть кандидатов 0 |
F3 бэкап перед UPDATE |
СДЕЛАНО, reclass.py:180 VACUUM INTO, :243 миграция category_code_prev |
НЕ ПРИМЕНЕНО: в боевой схеме колонок ['id','category_code','content','source_ids','media_links','is_case','confidence','processed_at','is_reclassified'] — category_code_prev НЕТ |
| F4 единый источник таксономии | НЕ СДЕЛАНО | 6 копий, п.5 |
F5 reply_to_msg_id в промпт |
СДЕЛАНО | distiller.py:808 в SELECT, MSG_x -> MSG_y в строке |
UNIQUE по хешу content |
НЕ СДЕЛАНО | в базе один индекс, idx_cat; content_hash колонки нет. Сброс is_processed_for_wiki сегодня удвоит базу |
| перепрогон 92 «пустых» серий / ~4 000 длинных непокрытых | НЕ СДЕЛАНО | кандидатов 0; без сброса флага прогон напечатает «архив полностью обработан» |
Остальные замеры разведки держатся на живой базе без изменений: is_case=1 — 10 862
(85 %), confidence=10 — 12 731, media_links непусто — 0 из 12 784, последняя
дата архива 2026-02-19 13:13:49 (то есть 160 дней корпус не пополнялся).
Пункт 2 этого отчёта (замер 09:21) утверждал: «в боевой stomat_wiki.db индексов нет
вообще». Это было неверно. Прямой запрос:
SELECT name, sql FROM sqlite_master WHERE type='index'
-> idx_cat: CREATE INDEX idx_cat ON distilled_facts(category_code)
объекты базы: ['distilled_facts', 'sqlite_sequence', 'idx_cat']
mtime базы — 2026-02-19 17:48:57, то есть за эту сессию в неё никто не писал, и
индекс существует с февраля. Прежний вывод «дистиллятор со своей схемой не
запускался» снят.
Но настоящий дефект на его месте оказался точнее и хуже. EXPLAIN QUERY PLAN на
живой базе, минимум из 5 прогонов:
| запрос | план | время |
|---|---|---|
как ищет бот: category_code LIKE '%1.2.1%' |
SCAN distilled_facts | 11.4 мс |
точным кодом: category_code = '1.2.1' |
SEARCH USING INDEX idx_cat | 0.1 мс |
префиксом: LIKE '1.2.1%' |
SCAN | 8.3 мс |
| пагинация подтемы (два кода + OFFSET 4000) | SCAN | 6.5 мс |
Ведущий % делает индекс непригодным: он существует, занимает место и не
используется ни одним запросом, который бот вообще выдаёт — 114-кратная разница на
том же самом столбце. Причина — хранение списка кодов строкой через запятую (п.5.2).
P3, честно: 11 мс врач не заметит. Правильное лечение — таблица связи
(fact_id, code), и она же убирает риск подстроки из п.5.2. Пока это не сделано,
idx_cat нужно считать мёртвым, а не работающим.
Чем доказать: EXPLAIN QUERY PLAN того запроса, который реально выдаёт
assistant.py, содержит SEARCH ... USING INDEX, а не SCAN.
Лана разведки не правит .py, поэтому диверсии проведены в изолированной песочнице
вне репозитория (C:\temp\recon_wave2\sandbox\ — копия всех 153 .py плюс копия
stomat_wiki.db). Боевые файлы и боевые базы не тронуты: git status показывает от
меня один _recon_wave2.md, mtime stomat_wiki.db = 2026-02-19 17:48:57.
Опорный прогон в песочнице до диверсий: test_budget_nesting 29/0,
test_voice_pipeline 52/0, test_tg_safety 131/0, test_wiki_pagination 40/0,
test_media_loop_guard 25/0.
| # | диверсия | прогнано | упало | поймано |
|---|---|---|---|---|
| 1 | таймаут ffmpeg 120 → 10000 (в 143 раза больше родительского дедлайна 70 с) |
test_budget_nesting 29, test_voice_offline 56, test_llm_failover 61, test_voice_pipeline 52 |
1 | формально да, но НЕ по смыслу — см. ниже |
| 2 | все 5 сайтов tg_safety.edit_message откачены на прямой bot_client.edit_message |
test_tg_safety 131, test_flood_discipline 35, test_group_quiz 28 |
0 | НЕТ |
| 3 | код 6.1.2 убран из assistant.WIKI_TREE (82 живых факта становятся недостижимы врачу) |
test_wiki_pagination 40, test_rag_quality 31 |
0 | НЕТ |
| 4 | обработчик горячего пути медиа обезмолвлен (logger.exception → pass) |
test_media_loop_guard 25 |
0 | НЕТ |
| 5 | обезмолвлен провал аварийной отметки медиа (_mark_media_processed) |
test_media_loop_guard 25 |
9 | ДА |
| 6 | правильная починка п.6.2: таймаут ffmpeg 120 → 45 (влезает в 70) |
test_voice_pipeline 52 |
1 | инверсия: тест ЗАЩИЩАЕТ дефект |
| 7 | запас сторожа сводки +600 → +100 |
test_budget_nesting 29 |
1 | ДА |
| 8 | контроль: summarizer.GEMINI_GENERATION_TIMEOUT_SECONDS 2100 → 9000 |
test_budget_nesting 29 |
0 | НЕТ — проверка тавтологична |
Диверсия 5 доказывает, что метод исправен: там, где тест писали под конкретное последствие (П3), он падает девятью проверками из двадцати пяти и печатает ровно последствие («строка останется в догоне», «файл будет качаться заново»). Значит нули в диверсиях 2, 3, 4, 8 — свойство тестов, а не моего стенда.
test_voice_pipeline.py:425-428:
_gc_src = _io.open("gemini_client.py", encoding="utf-8").read()
check("у ffmpeg есть таймаут",
"timeout=120" in _gc_src.split("def _ffmpeg_to", 1)[1][:900],
"без таймаута ffmpeg на битом файле висит бесконечно")Это сравнение ТЕКСТА ИСХОДНИКА, приколачивающее конкретное число 120 — то самое, которое по п.6.2 в родительский дедлайн 70 с не влезает. Замер обеих сторон:
- диверсия 1 (
120 → 10000, дефект усилен в 83 раза): проверка падает — но падает она на изменении литерала, а не на нарушении вложенности. Остальные 197 проверок четырёх тестов молчат. - диверсия 6 (
120 → 45, дефект УСТРАНЁН): проверка падает точно так же.
То есть агент следующей волны, который починит п.6.2 правильно, получит красный тест
и, скорее всего, откатит правку. Это ровно тот класс, про который правила проекта
говорят «проверка «в исходнике есть такая строка» НЕ СЧИТАЕТСЯ», и здесь он не просто
бесполезен, а работает против починки.
Чем доказать: проверка сравнивает число с
VOICE_TRANSCRIBE_TIMEOUT_SECONDS + _SUBPROCESS_STARTUP_SLACK_SECONDS и требует
строгого «меньше», а не наличие подстроки.
Диверсия 8 подняла бюджет генерации сводки с 2100 до 9000 с — и
test_budget_nesting дал 29/0. Причина в main.py:90:
SUMMARY_STALE_SECONDS = summarizer.GEMINI_GENERATION_TIMEOUT_SECONDS + 600Проверка [1] сравнивает X < X + 600. Она истинна при любом X и печатает
[OK ] генерация сводки (2100) укладывается в терпение сторожа (2700), создавая
впечатление замера. Сама производность — правильное решение (тест её же и требует
проверкой [1].2), но после неё сравнение обязано было смениться на сравнение
ЗАПАСА со суммой этапов. Такая проверка в наборе есть ([1].3) и она работает:
диверсия 7 (+600 → +100) её роняет.
Разбор всех 29 проверок test_budget_nesting.py по типу:
| тип | сколько | что это |
|---|---|---|
| настоящее сравнение двух независимых чисел | 20 | падают на расхождении, работают |
| текстовое сравнение с исходником | 5 | :64-68, :122-125, :182-190 — переживут переделку |
| самопроверка функции сравнения | 3 | группа [7], полезна как мета |
| тавтология | 1 | [1].1 — упасть не может |
Оговорка: 20 работающих проверок — это много, и набор в целом полезен. Но «29 PASSED, 0 FAILED» читается как «бюджеты согласованы», хотя пятый случай класса (п.6.2) сидит внутри области, которую тест декларирует своей, и не виден ни одной из 29.
- Диверсия 2 (0 из 194). Ничто в дереве не удерживает принятие
tg_safety. Слой можно откатить целиком, и 194 проверки трёх тестов не заметят. Это прямое подтверждение п.8.2: 603 строки кода плюс 869 строк тестов проверяют МОДУЛЬ и ноль проверок — его ИСПОЛЬЗОВАНИЕ. - Диверсия 3 (0 из 71). Таксономию можно молча обрезать: 82 факта уходят из
доступа врача,
test_wiki_paginationиtest_rag_qualityпропускают. Ни один тест не связывает кнопки врача с содержимым корпуса — это и есть отсутствующий тест из п.5.3. - Диверсия 4 (0 из 25).
test_media_loop_guard.pyнаписан под ОДИН сайт (_mark_media_processed) и соседний обработчик того же горячего пути не защищает. Это не дефект теста — это указание, где границы покрытия: 24 немых обработчикаexcept Exception: passиз п.3.4 не проверяет никто.
Одной фразой: репозиторий перестал быть набором файлов в трёх подсистемах
(summarizer + tg_safety-модуль, конвейер медиа, дистиллятор-код) и остался набором
файлов там, где качество ПОСТРОЕНО, НО НЕ ПОДКЛЮЧЕНО. Два самых больших вложения
сессии — tg_safety.py (603+869 строк) и web_lookup.py (499+541) — до врача не
доходят: 5 сайтов принятия из 110 и 0 вызовов соответственно.
| P | что | где | чем доказать |
|---|---|---|---|
| P1 | test_voice_pipeline.py:426 приколачивает timeout=120 и роняет правильную починку вложенности |
test_voice_pipeline.py:425-428 |
сравнение с VOICE_TRANSCRIBE + SLACK, а не подстрока; диверсия 120→45 должна проходить |
| P1 | ffmpeg: 120 с + до 60 с на перебор кандидатов внутри 70 с родителя | gemini_client.py:737, :666 против main.py:1181 + blocking_tools.py:224 |
фиктивный ffmpeg, спящий дольше бюджета: расшифровка возвращает отказ С ПРИЧИНОЙ |
| P1 | рабочий ffmpeg.exe (99 МБ) лежит в корне репозитория и не входит в кандидатов |
gemini_client.py:684-700 |
ffmpeg_binary() возвращает путь, который печатает ffmpeg version; иначе громкий SKIP |
| P1 | tg_safety принят на 5 сайтах из 110; 103 обращения к Telegram без границ |
assistant.py |
AST-проверка числа обращений МИМО tg_safety + мок, спящий дольше бюджета |
| P1 | 13 except Exception: pass на горячем пути (ЛС врача, снимок, разбор случая) |
assistant.py:418, 1255, 2073, 2082, 2127, 2564, 2746, 2749, 3106, 3946, 5041; media_tools.py:62, 161 |
на каждый сайт: исключение в зависимости → запись с типом в журнале + отрицательный результат вызывающему |
| P1 | защита от дубля дайджеста и восстановление после таймаута отправки не работают НИКОГДА (бот-аккаунт) | summarizer.py:164-176, main.py:711 |
клиент с «restricted» → ERROR с названным последствием; рядом есть пользовательский client |
| P1 | гео-блокировка классифицирована как временная ошибка → до 48 обречённых запросов | gemini_client.py:88 |
классификатор на живом тексте ошибки даёт False; каскад ходит один раз |
| P1 | 7 скриптов с правом записи в боевые данные, ноль тестов | reclass, videosi, import_videos, deppd, visionproc, savdel, filemake, prompter |
прогон на временной копии базы + проверка, что боевой файл не изменился |
| P2 | уборщик чистит _converted.wav, перекодирование пишет _converted.ogg |
blocking_tools.py:803 против gemini_client.py:785 |
имя строит одна функция; тест создаёт оба файла и требует ноль остатков |
| P2 | 6 копий таксономии, расхождение по 5 кодам; 6.1.2 (82 факта) знает только assistant |
п.5 | один источник + тест «каждый живой код имеет кнопку, каждый код промпта имеет имя» |
| P2 | тавтологическая проверка вложенности [1].1 |
test_budget_nesting.py:61-63 |
диверсия 2100→9000 обязана ронять набор |
| P2 | main.transcribe_group_voice, main.media_analysis_worker, html_safe.balance_html и ещё 30 публичных функций не названы ни одним тестом |
п.7.2 | пересчёт coverage.py: число падает |
| P2 | немота выросла: 104 немых обработчика против 87, 115 записей печатают только str(exc) против 109 |
п.3.3 | пересчёт тем же скриптом после волны |
| P2 | search_engine.py (сетевой импорт на уровне модуля) дублирует search_engine_safe.py |
п.1 поправка | удалить; test_import_safety.py должен ловить такой импорт |
| P2 | test_import_safety.PRODUCTION перечислен руками, tg_safety.py в него не входит |
test_import_safety.py:42 |
список выводится из графа импортов main.py |
| P2 | 8 тестов (1582 строки) не импортируют боевой код вообще | п.7.3 | перечитать отдельной волной |
| P3 | idx_cat существует и не используется ни одним запросом бота (SCAN 11.4 мс против SEARCH 0.1 мс) |
п.8.4 | EXPLAIN QUERY PLAN даёт SEARCH ... USING INDEX |
| P3 | bot.log: 798 строк тестовой выдумки не вычищены |
п.8.1 | ротация файла, не правка кода |
| P3 | долг не помечен в коде ни разу (TODO/FIXME/HACK/XXX = 0), живёт только в 19 .md |
п.4 | пометки # ДОЛГ: + инвентарь, который их считает |
| P3 | голый except: — один, prompter.py:33 |
п.3.2 | except (IndexError, ValueError) + запись |
| P3 | docstring assistant.py:3723 противоречит коду на 19 строк ниже |
п.4 | — |
Код дистилляции починен, боевые данные — нет: 353 факта с битыми source_ids
(2 073 токена, восстановимо 1 985 id), нет колонки category_code_prev, нет
UNIQUE по хешу content (сброс флага сегодня удвоит базу), очередь дистилляции
даёт 0 кандидатов, архив кончается 2026-02-19 (160 дней без пополнения).
- Частоту пути перекодирования аудио измерить нечем: в архиве 0 реплик с
media_typevoice/audio,temp_media/пуст,uploaded_media/— только.jpg. Дефекты 6.2 и 6.3 доказаны чтением кода и живым замером стоимости поиска ffmpeg, но не наблюдённой утечкой и не наблюдённым убийством. - Покрытие «упоминание имени функции в тесте» — ВЕРХНЯЯ оценка. Реального
покрытия (
coverage.pyот Python) я не запускал. - Достижимость «горячий путь» посчитана графом вызовов по ИМЕНАМ, без разрешения
типов, — тоже верхняя оценка, как и в
_fix_observe.md. - Дерево двигалось под семью агентами всё время замера: числа п.1-2 сняты в 09:21,
п.3-9 — в 09:58-10:26. Принятие
tg_safetyперепроверено последним (10:26, по-прежнему 5 сайтов), ноassistant.pyв этот момент был изменён другим агентом, и к моменту чтения отчёта число может отличаться. Скрипты пересчёта лежат вC:\temp\recon_wave2\—imports.py,bare.py,tax4.py,tax5.py,wikimatch.py,budgets.py,coverage.py,adoption.py,strexc.py,datastate.py,planprobe.py,ffprobe.py. run_all_tests.pyя не запускал (запрещено заданием), поэтому о состоянии набора ЦЕЛИКОМ ничего не утверждаю: прогоняли только 11 отдельных тестов и только в песочнице либо на чтение.