Skip to content

Latest commit

 

History

History
296 lines (231 loc) · 26.7 KB

File metadata and controls

296 lines (231 loc) · 26.7 KB

Лан: два скрипта дистилляции (reclass.py, videosi.py)

Владение: 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) НЕ запускались. Контрольные суммы боевых БД — в конце файла.

Схема боевой вики (замер, PRAGMA)

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 их перезапишет без копии.

Д2, замер провенанса видео-фактов (проверка числа 35/53)

Видео-факты опознаны по 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.


Вторая волна лана (после смерти первой на «Credit balance is too low»)

Первая волна успела внести правки в 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 остаётся заряженным ружьём — см. «для лида».

Проверка чужой правки: VACUUM INTO ? вообще исполняется

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

  1. Проверка копии вынесена в verify_copy(target, expected) (было — телом внутри backup_before_write). Причина не косметическая: пока ветки отказа лежали внутри, их нельзя было проверить подложенным файлом, и две из них оказались декоративными — диверсии S7/S8 не уронили ни одной проверки. После выноса тест подкладывает 0-байтную, мусорную, обрезанную и укороченную копию. Замер, ради которого это делалось: обрезанная на 2048 байт копия ОТКРЫВАЕТСЯ и отдаёт правильный COUNT(*) = 200, а integrity_check при этом говорит row 181 missing from index idx_cat. То есть без сверки integrity такая копия молча считалась бы годной, и врач узнал бы правду в день отката.
  2. Прогон впустую больше не оставляет копию. На боевом снимке is_reclassified = 0 ровно у НУЛЯ фактов, значит каждый запуск «на всякий случай» писал копию на 9 158 656 байт и не менял ничего. Теперь число незаклассифицированных считается ДО копии, и при нуле прогон выходит, не создав файла.
  3. os.path.abspath в URI копии — относительный путь давал относительный file:-URI.

videosi.py

  1. 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) оставлены как есть — они исполняются и держат диверсии, см. ниже.

Тест: test_distill_scripts_safety.py, 89 проверок, 0 провалов

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 боевых баз до и после прогона совпадает

Саботаж: 11 диверсий, поймано 10

Драйвер _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.