Владение: reclass.py, videosi.py, test_distill_scripts_safety.py. Всё остальное — текстом лиду.
Боевые БД открывались ТОЛЬКО через file:...?mode=ro. Ни одного INSERT/UPDATE/DELETE/VACUUM в боевые
stomat_wiki.db / stomat_archive.db. Сами скрипты (reclass.py, videosi.py) НЕ запускались.
Контрольные суммы боевых БД — в конце файла.
CREATE TABLE distilled_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT, category_code TEXT, content TEXT,
source_ids TEXT, media_links TEXT, is_case BOOLEAN, confidence INTEGER,
processed_at TIMESTAMP, is_reclassified BOOLEAN DEFAULT 0)
единственный индекс: CREATE INDEX idx_cat ON distilled_facts(category_code)
- UNIQUE-ограничений нет ни одного.
PRAGMA index_list('distilled_facts')-> толькоidx_cat,unique=0. ЗначитINSERT OR IGNOREсам по себе НИЧЕГО не отсечёт — нужна миграция (см. «для лида»). - Колонки
category_code_prevнет: перезаписьcategory_codeвreclass.py:180действительно безвозвратна. is_reclassified: 0 -> 0 строк, 1 -> 12 784. То есть на ЭТОМ снимкеreclass.pyобновил бы ноль строк. Опасность не в снимке, а в том, что любая новая волнаdistiller.pyсоздаёт строки сis_reclassified = 0(DEFAULT), и следующий прогон reclass их перезапишет без копии.
Видео-факты опознаны по content LIKE '%ВИДЕО-ПРОТОКОЛ%': 53 строки, id 12692..12744.
Это же ровно те 53 факта, у которых confidence = 100 (у остальных 12 731 — константа 10).
| Замер | Значение |
|---|---|
| видео-фактов | 53 |
source_ids числовые |
53 из 53 (нечисловых нет) |
source_ids НАЙДЕНЫ как msg_id в архиве |
35 |
source_ids не найдены в архиве |
18 |
из 35 найденных: has_media = 1 |
6 |
из 35 найденных: media_type = 'video' |
1 (msg_id 1861) |
MSG N в тексте факта != source_ids |
0 (номер один и тот же, оба из текстового файла) |
35/53 подтверждено моим замером. Число сходится с прошлой волной.
Хуже, чем «35 совпали»: совпавшие — не видео. Из 35 попаданий медиа есть только у 6, и лишь у ОДНОГО
тип video. Остальные 34 — текстовые реплики чата. Примеры (факт -> реальная реплика архива):
факт 12693 source_ids=1548 «ВИДЕО-ПРОТОКОЛ | MSG 1548»
архив 1548: «Поговорим немножко об overcontour и undercontour?..» has_media=0
факт 12694 source_ids=1899 «ВИДЕО-ПРОТОКОЛ | MSG 1899»
архив 1899: «Удаляем таких» has_media=0
факт 12695 source_ids=8614 «ВИДЕО-ПРОТОКОЛ | MSG 8614»
архив 8614: «Апроксимально проверить. По контактам похоже не просело» has_media=0
Диапазон msg_id архива — 5..139701, а номера в videos.txt четырёх-значные, поэтому совпадения
неизбежны: это коллизия нумерации, а не провенанс. Врач, открывший ссылку, попадёт на чужую реплику
про контактные пункты вместо видео-разбора и решит, что бот врёт.
videos.txt на этой машине НЕТ — восстановить настоящие msg_id из входного файла нечем.
Строгую проверку (существует + has_media + видео-тип) прошёл бы 1 факт из 53.
Первая волна успела внести правки в reclass.py и videosi.py, но НЕ успела написать тест и НЕ
проверила свои правки поведенчески. test_distill_scripts_safety.py не существовало. Вторая волна:
переизмерила всё сама, проверила чужие правки на исполнение, добавила тест.
_measure_distill_scripts.py, обе базы через file:...?mode=ro. Контрольные суммы ДО и ПОСЛЕ работы
совпадают (см. конец файла) — ни одной записи в боевые базы.
| Замер | Значение |
|---|---|
| фактов в вики | 12 784 |
is_reclassified = 1 |
12 784 (0 → ноль строк) |
category_code_prev в боевой схеме |
нет |
UNIQUE-ограничений на distilled_facts |
ни одного (единственный индекс idx_cat, unique=0) |
| видео-фактов | 53 (id 12692..12744) |
source_ids числовые |
53 из 53 |
source_ids найдены как msg_id архива |
35 |
из 35: has_media = 1 |
6 |
из 35: media_type = 'video' |
1 |
реплик в архиве / диапазон msg_id |
117 847 / 5..139 701 |
35/53 подтверждено независимо. Число сходится. Совпавшие — чужие текстовые реплики:
факт 12693 ссылается на msg_id 1548 = «Поговорим немножко об overcontour и undercontour?..»,
факт 12694 на 1899 = «Удаляем таких». Провенанс ложный, врач по нему читает не тот разбор.
Это была главная подозреваемая. import_videos.py:102 (не мой файл) пишет шапку с тегами <b>:
🎥 <b>[ВИДЕО-ПРОТОКОЛ | MSG N]</b>, а VIDEO_MARKER в videosi.py — без <b>. Если бы боевые 53
строки были написаны import_videos.py, защита от повтора не увидела бы ни одной из них.
Замер боевого снимка: все 53 строки начинаются с маркера БЕЗ <b>, с <b> — ноль. Прогон ровно
того SQL, которым ищет already_imported, распознал повтор для 53 из 53; при повторном прогоне
было бы вставлено заново 0. Маркер верен, тревога снята. Но import_videos.py остаётся заряженным
ружьём — см. «для лида».
backup_before_write — новый код, который никто не запускал. Если бы VACUUM INTO с параметром не
работал через aiosqlite, бэкап падал бы ВСЕГДА и reclass.py не обновил бы ни одного факта никогда.
_probe_vacuum_param.py, временный каталог, Python 3.13.14 / SQLite 3.50.4:
VACUUM INTO ?с параметром через aiosqlite — работает, копия 12 288 байт,integrity_check= ok, число фактов в копии совпало;- второй
VACUUM INTOв тот же файл —OperationalError: output file already exists. Значит секунды в имени копии и проверкаos.path.existsне перестраховка: без них второй прогон за сутки падал бы; VACUUMпосле незакоммиченногоUPDATEв том же соединении —cannot VACUUM from within a transaction. Значит отдельное соединение для бэкапа обязательно, и оно там действительно отдельное;config.GOOGLE_KEYS— список из 10 ключей, ранний выход по пустому конфигу не сработает вслепую.
verify_provenance теперь возвращает "" вместо номера. Проверил обоих потребителей source_ids:
savdel.py:180—[x.strip() for x in (s_ids or '').split(',') if ... isdigit()]→ на""даёт пустой список, запрос к архиву не идёт. Безопасно;filemake.py:52— печатаетИсточники (MSG IDs): {sources}→ печатает пусто. Безопасно.
Ни один не падает. (В боевой вике source_ids IS NULL встречается и сейчас, так что путь уже обкатан.)
reclass.py
- Проверка копии вынесена в
verify_copy(target, expected)(было — телом внутриbackup_before_write). Причина не косметическая: пока ветки отказа лежали внутри, их нельзя было проверить подложенным файлом, и две из них оказались декоративными — диверсии S7/S8 не уронили ни одной проверки. После выноса тест подкладывает 0-байтную, мусорную, обрезанную и укороченную копию. Замер, ради которого это делалось: обрезанная на 2048 байт копия ОТКРЫВАЕТСЯ и отдаёт правильныйCOUNT(*) = 200, аintegrity_checkпри этом говоритrow 181 missing from index idx_cat. То есть без сверки integrity такая копия молча считалась бы годной, и врач узнал бы правду в день отката. - Прогон впустую больше не оставляет копию. На боевом снимке
is_reclassified = 0ровно у НУЛЯ фактов, значит каждый запуск «на всякий случай» писал копию на 9 158 656 байт и не менял ничего. Теперь число незаклассифицированных считается ДО копии, и при нуле прогон выходит, не создав файла. os.path.abspathв URI копии — относительный путь давал относительныйfile:-URI.
videosi.py
SLEEP_BETWEEN_VIDEOS = 2вместо литерала вasyncio.sleep(2). Без этой константы проверка защиты от дублей стоила бы 53 x 2 = 106 секунд высиживания, то есть её просто не написали бы. Ровно та же причина, по которой вreclass.pyуже вынесенSLEEP_BETWEEN_FACTS.
Правки первой волны (category_code_prev, VACUUM INTO до первого UPDATE, already_imported по
началу строки, verify_provenance) оставлены как есть — они исполняются и держат диверсии, см. ниже.
PASSED: 89 FAILED: 0
Что доказано ПОВЕДЕНЧЕСКИ (возвращаемое значение, содержимое базы, строка в журнале — не «в исходнике есть такая строка»):
| Блок | Что проверено |
|---|---|
| [1] | миграция идемпотентна; база без distilled_facts даёт RuntimeError, а не молчаливый pass |
| [2] | копия читается, integrity_check = ok, число фактов совпало, в копии ПРЕЖНИЕ коды; отказ на 0 байт / мусоре / обрезанной / укороченной копии; копия поверх существующей не пишется |
| [3] | без успешной копии — ни одного UPDATE: SystemExit 1, обе строки в журнале, ни одна строка не изменилась, файла копии не осталось |
| [4] | прежний код в category_code_prev; копия содержит прежние коды, значит снята ДО первого UPDATE; уже размеченный факт не тронут |
| [5] | нечего переклассифицировать -> копии нет, база не тронута |
| [6] | сломанный классификатор и вечный RETRY не вешают прогон и не портят разметку (category_code_prev остаётся NULL) |
| [7] | повторный прогон videosi не увеличивает число строк (3 -> 3), дублей нет; отдельно проверена сама save_to_db_safe |
| [8] | MSG 148 не считается копией MSG 1489 |
| [9] | провенанс пустой вместо ложного, сквозным прогоном: source_ids = '' у непроверяемого |
| [10] | в category_code уезжают только цифровые коды |
| [11] | живой снимок: все 53 протокола опознаются как повтор; 35/6/1 пересчитано тестом |
| [12] | md5 боевых баз до и после прогона совпадает |
Драйвер _sabotage_distill_scripts.py (сам себя не выполняет при импорте — он переписывает боевые
файлы, а в этом проекте импорт такого файла уже один раз испортил assistant.py). Каждая диверсия
восстанавливается из бэкапа сразу после прогона; итоговый прогон — снова 89/0.
| Диверсия | Упало проверок | Итог |
|---|---|---|
| S1 прежний код не сохраняется (откат D1a) | 2 | поймано |
| S2 провал копии перестаёт быть фатальным (откат D1b) | 3 | поймано |
| S3 копия не снимается вообще | 6 | поймано |
S4 маркер обрастает <b> (как в import_videos.py) |
2 | поймано |
S5 защита от повтора снова ищет подстрокой LIKE |
1 | поймано |
| S6 ложный провенанс возвращается (откат D2) | 3 | поймано |
S7 size <= 0 -> size < 0 |
0 | НЕ поймано |
S8 integrity_check не сверяется |
2 | поймано |
| S9 число фактов в копии не сверяется | 1 | поймано |
| S10 отсутствие файла копии не проверяется | 1 | поймано |
S11 защита от повтора внутри save_to_db_safe отключена |
2 | поймано (после правки теста) |
Две записи важнее остальных:
S7 не поймана, и это не дыра, а избыточность. Проверил отдельно: 0-байтный файл SQLite открывает,
integrity_check на нём отвечает ok (!), а SELECT COUNT(*) падает no such table — то есть копия
всё равно отвергается, просто веткой «копия не читается». Свойство «негодную копию не принимаем»
сохраняется без size <= 0. Утверждать, что тест это ловит, было бы неправдой; утверждать, что здесь
дыра, — тоже.
S11 сначала не поймалась (0 упавших проверок) — это была настоящая дыра в тесте. У main() есть
свой предварительный probe-запрос, и он маскирует внутреннюю защиту save_to_db_safe: со снятой
внутренней проверкой сквозной прогон проходил чисто. А внутренняя проверка — последняя линия, она
держит гонку двух одновременных прогонов, когда probe уже сказал «такого нет». Добавил прямой вызов
save_to_db_safe дважды — теперь диверсия валит 2 проверки.
| Тест | Результат | Чей дефект |
|---|---|---|
test_import_safety.py |
258 / 0 | — (мои новые файлы его проходят) |
test_isolation.py |
107 / 2 | НЕ мой: test_media_loop_guard.py изолирует базу |
test_distill_pipeline.py |
143 / 1 | НЕ мой: листья сита вне CAT_MAP — расхождение distiller.LEAF_CODES и savdel.py CAT_MAP |
run_all_tests.py не запускался (запрещено брифом).
1. Миграция UNIQUE для videosi.py. Проверена на ВРЕМЕННОЙ копии, на боевой базе не создавалась.
UNIQUE в distilled_facts нет ни одного, поэтому INSERT OR IGNORE сейчас не отсекает ничего — дубли
держит только Python-проверка. Предлагаю узкий индекс по номеру протокола, а не по тексту факта:
ALTER TABLE distilled_facts ADD COLUMN protocol_msg_id INTEGER;
UPDATE distilled_facts
SET protocol_msg_id = CAST(substr(content,
instr(content, 'MSG ') + 4,
instr(content, ']') - instr(content, 'MSG ') - 4) AS INTEGER)
WHERE content LIKE '%ВИДЕО-ПРОТОКОЛ | MSG %';
CREATE UNIQUE INDEX IF NOT EXISTS idx_protocol_msg_id ON distilled_facts(protocol_msg_id);
Замер на копии боевых данных (53 настоящих видео-факта + 200 настоящих обычных, _measure_unique_migration.py):
protocol_msg_idзаполнился у 53 из 53, номера разобраны верно (1377, 1548, 1899, 8614...), мусора 0;- у 200 обычных фактов остаётся NULL, и множественные NULL уникальности не нарушают — проверено;
- повторный
INSERT OR IGNOREтого же протокола: строк было 253, стало 253 — отсекла база; - без
OR IGNORE:IntegrityError: UNIQUE constraint failed: distilled_facts.protocol_msg_id; - повторный
ALTERдаётduplicate column name— оборачивать так же, как вreclass.ensure_schema; - цена:
UNIQUE(protocol_msg_id)+4 096 байт,UNIQUE(content)+593 920 байт на тех же 253 фактах, в 145 раз дороже. На 12 784 фактах индекс по тексту добавил бы порядка 29 МБ к базе на 9 МБ.
После миграции videosi.py нужна правка на 2 строки — добавить protocol_msg_id в список колонок
INSERT и msg_id в параметры. САМ не сделал намеренно: на непромигрированной боевой базе такой INSERT
упал бы no such column, и импорт не записал бы ни одного протокола. Это правка «после миграции», а не
«вместе с ней».
2. import_videos.py — заряженное ружьё (не мой файл). Он пишет шапку 🎥 <b>[ВИДЕО-ПРОТОКОЛ | MSG N]</b> с тегами <b>, а защита от повтора в videosi.py сравнивает начало строки БЕЗ <b>. Боевые 53
строки сейчас без <b> (замерено), так что расхождения нет. Но если кто-нибудь запустит
import_videos.py, его записи станут невидимыми для защиты videosi.py, и следующий прогон
продублирует их. Предлагаю либо удалить import_videos.py (его работу полностью делает videosi.py,
плюс у него нет ни защиты от повтора, ни ротации ключей, ни проверки провенанса), либо привести шапку
байт-в-байт к VIDEO_MARKER. Диверсия S4 показывает, что тест это расхождение видит.
3. Провенанс: 52 ссылки из 53 станут пустыми. Это правка первой волны, я её только проверил. У
существующих 53 строк в боевой базе source_ids по-прежнему ЛОЖНЫЙ — код лечит будущие импорты, а не
прошлые. Чтобы вылечить прошлые, нужен UPDATE по боевой базе; сам не делал (боевая база только на
чтение). Текст, если решишь: UPDATE distilled_facts SET source_ids = '' WHERE content LIKE '%ВИДЕО-ПРОТОКОЛ%' AND source_ids <> '1861'; — под бэкап, и лучше через reclass.backup_before_write,
раз он теперь проверенный.
4. videos.txt на этой машине нет. Настоящие msg_id для 53 протоколов восстановить нечем. Если
файл найдётся на боевой машине — по нему можно проверить, номера это строк или всё-таки сообщений.
- Сами скрипты не запускались — ни
reclass.py, ниvideosi.py, ни на боевой базе, ни на копии целиком: оба вызывают Gemini. Всё, что проверено, проверено через подменённые точки выхода наружу (classify_fact,classify_video,genai,config). Реальный ответ Gemma 3 27B не участвовал ни в одном прогоне. - Гонка
VACUUM INTOиSELECT COUNT(*). Копия снимается, потом считаются факты в источнике. Еслиdistiller.pyвставит факт между этими двумя запросами,copied != expectedи прогон откажется работать с сообщением «в копии N фактов вместо N+1». Направление отказа безопасное (ни одного UPDATE), но это ложная тревога, и я её НЕ устранял: строгая починка требует держать читающую транзакцию, а внутри транзакцииVACUUMзапрещён (проверено:cannot VACUUM from within a transaction). - Копия в одну секунду. Имя копии различается секундами; два прогона внутри одной секунды дают «файл копии уже существует» и второй откажется работать. Для реального прогона (VACUUM 9 МБ + классификация) это недостижимо, специально не лечил. На этом сначала падал мой же тест.
- Единственная «подтверждённая» ссылка (msg_id 1861) — тоже совпадение, просто прошедшее строгий
фильтр (существует +
has_media = 1+media_type = 'video'). Что это ИМЕННО то видео, из которого сделан разбор, ничем не доказано. Строгий фильтр снижает вероятность вранья, но не устраняет её. - Всё измерено на снимке
stomat_wiki.dbот 19 февраля (12 784 факта,is_reclassified = 1у всех). Бот живёт на другой машине; там число незаклассифицированных фактов почти наверняка другое, и именно тамreclass.pyреально что-то перезапишет. test_isolation.pyиtest_distill_pipeline.pyпадают по одной проверке каждый — в чужих файлах (test_media_loop_guard.py,savdel.py/distiller.py). Я их не трогал и не проверял, мои ли правки на них влияют (не влияют по построению: ни один из них не импортируетreclass/videosi).
stomat_wiki.db 2207af7649...(md5, укорочен) 9 158 656 байт
stomat_archive.db addae58b92...(md5, укорочен) 47 587 328 байт
Совпадают со снятыми ДО работы. Ни одного INSERT/UPDATE/DELETE/VACUUM/ATTACH в боевые базы не было;
открывались только через file:...?mode=ro.