Файлы лана: reclass.py, videosi.py, import_videos.py, test_json_scan.py (создать).
Пишется инкрементально по ходу работы. Git не трогался вообще.
Скрипты замеров: _measure_json_scan.py, _measure_json_scan2.py, _measure_json_scan3.py.
reclass.clean_json_raw:99 и videosi.clean_json_raw:63 байт-в-байт одинаковы
(re.search(r'\{.*\}', text, re.DOTALL)). Замер на 9 реалистичных ответах модели:
| ответ модели | reclass | videosi | сканер distiller._iter_json_objects |
|---|---|---|---|
| 1 чистый объект | OK | OK | OK |
2 тройные кавычки с json |
OK | OK | OK |
| 3 болтовня ДО объекта | OK | OK | OK |
4 болтовня ПОСЛЕ объекта, в ней } |
ПЛОХО Extra data |
ПЛОХО | OK |
| 5 два объекта в одном ответе | ПЛОХО Extra data |
ПЛОХО | OK (оба) |
| 6 модель повторила образец формата из промпта | ПЛОХО Extra data |
ПЛОХО | OK после цифрового фильтра |
7 вложенный объект + болтовня со } |
ПЛОХО Extra data |
ПЛОХО | OK |
| 8 обрыв на середине объекта | ПЛОХО | ПЛОХО | пусто -> нужен громкий отказ |
| 9 совсем без объекта | ПЛОХО | ПЛОХО | пусто -> нужен громкий отказ |
Итог замера: копии согласны 9 из 9 (гипотеза расхождения ОПРОВЕРГНУТА независимо от разведки), обе проваливают 6 из 9. Что именно вырезает жадная регулярка:
'Вот результат: {"codes": ["2.3.2"]} — надеюсь, помогло }'
-> '{"codes": ["2.3.2"]} — надеюсь, помогло }' (Extra data)
'префикс {"codes": ["1.2.1"]} суффикс {"codes": ["1.2.6"]}'
-> '{"codes": ["1.2.1"]} суффикс {"codes": ["1.2.6"]}' (Extra data)
'Итог {"codes": ["6.2.1"], "meta": {"sure": true}} (см. правило 3 })'
-> '{"codes": ["6.2.1"], "meta": {"sure": true}} (см. правило 3 }' (Extra data)
Задание и разведка называют строку 569. Замер: def _iter_json_objects(text) стоит
на distiller.py:444, единственный вызов — distiller.py:509 (внутри
parse_facts). На 569 строке лежит init_wiki_db/DDL. Число в задании неверно.
Жадный rfind('}') в distiller живёт в clean_json_string (:431..441), это
третья копия того же класса, но файл не мой — см. «для лида».
reclass классифицирует ОДИН факт за вызов, поэтому «пачка фактов» — не тот
масштаб. Замер (_measure_json_scan.py, замер 3):
- болтовня после объекта ->
classify_factвозвращаетNone->fails += 1; послеMAX_FAILS_PER_FACT = 3факт помечается[ПРОПУСК], остаётся со старым кодом иis_reclassified = 0. Цена: 3 вызова модели и 5.4 с сна на факт. - тот же объект без болтовни ->
'2.3.2'. То есть теряется именно из-за болтовни.
videosi (замер 4): ключей в config.GOOGLE_KEYS — 10. Болтовня после объекта
-> classify_video перебирает все 10 ключей МОЛЧА (except Exception: continue)
и возвращает FALLBACK_CODE = '10.1', а в main это ветка «НЕ ЗАПИСАН»: видео-
протокол в базу не попадает вовсе. Для врача: разбора нет в рубрикаторе, и никто
не видит причины — 10 вызовов сгорели без единой строки в журнале.
json.loads на обрезанном объекте даёт Expecting ',' delimiter. Внутри слова
delimiter сидит подстрока limit, а classify_fact ловит все исключения
одним except и проверяет if "429" in err or "limit" in err or "exhausted" in err: return "RETRY". Замер (_measure_json_scan3.py):
'{"codes": ["1.1.1"' -> "Expecting ',' delimiter: ..." 'limit'? True
'{"codes": ["1.1.1"]' -> "Expecting ',' delimiter: ..." 'limit'? True
'{"codes"' -> "Expecting ':' delimiter: ..." 'limit'? True
Сквозной прогон reclass.main() на временной копии боевой схемы (6 фактов, модель
отдаёт обрыв): 13 вызовов модели, 26 с сна, переклассифицировано 0 из 6, прогон
оборван на ПЕРВОМ факте, в журнале — [СТОП] Все ключи в лимите на ID 1 после 13 ротаций. Диагноз ложный: ключи целы, ответ обрезан. И последняя строка прогона —
--- BASE RECLASSIFIED SUCCESSFULLY ---, то есть оператор читает УСПЕХ там, где не
переклассифицирован ни один факт из 12 784, и повторного прогона не делает.
Это тот самый класс «совпадение подстрокой», который в проекте ловился 8 раз;
здесь он спрятался внутри английского слова delimiter.
Замер (_measure_json_scan2.py), что уезжает в category_code у reclass:
| ответ модели | вернулось | что будет с фактом |
|---|---|---|
| пустая строка | '10.1' |
ЗАПИШЕТ В БАЗУ, is_reclassified = 1 |
None вместо текста |
'10.1' |
ЗАПИШЕТ В БАЗУ |
{"category": "Эндодонтия"} (нет ключа codes) |
'10.1' |
ЗАПИШЕТ В БАЗУ |
{"codes": []} |
'10.1' |
ЗАПИШЕТ В БАЗУ |
{"codes": ["ЭНДОДОНТИЯ"]} |
'10.1' |
ЗАПИШЕТ В БАЗУ |
| обрыв | 'RETRY' |
ложная ротация, см. 0.4 |
| болтовня после объекта | None |
3 попытки -> [ПРОПУСК] |
Кода 10.1 нет ни в одной подтеме рубрикатора (это же зафиксировано в
videosi.py:29-34 и test_video_import.py:36), поэтому факт с ним недостижим для
врача НАВСЕГДА: is_reclassified = 1 означает, что следующий прогон его не возьмёт,
а прежний код уже затёрт. videosi в этих случаях ведёт себя безопаснее — не
записывает вовсе, но платит 10 вызовами и молчанием.
Задание: «ruff нашёл мёртвую переменную title_candidate, около строки 94».
Замер: живой import_videos.py — 47 строк, raise SystemExit(_STOP) на уровне
модуля, слова title_candidate в нём нет; ruff 0.15.16 --select F на нём даёт
0 находок. F841 реально существует, но в исключённой копии органайзера
stomchat/import_videos.py:94 (126 строк, версия до снятия с вооружения,
в .gitignore; rg по умолчанию её не видит, поэтому и «не находится»):
F841 Local variable `title_candidate` is assigned to but never used
--> stomchat\import_videos.py:94
Заголовок при этом НЕ терялся даже в той старой версии: в записи уходило
final_content = f"🎥 <b>[ВИДЕО-ПРОТОКОЛ | MSG {msg_id}]</b>\n\n{body}", то есть
ВЕСЬ body вместе с первой строкой. first_line и title_candidate были обе
мёртвые — заготовка «красивого заголовка», которая никуда не подключилась. В живом
videosi.py:242 то же самое без потери: final_content = VIDEO_MARKER + msg_id + body.
Так что это не потеря заголовка, а мёртвый код в исключённом дубликате. Правок в
import_videos.py не делаю.
Единственное, что ruff нашёл в МОИХ живых файлах: F401 time imported but unused
в reclass.py:11 и videosi.py:11. Не трогаю — косметика вне лана, см. «для лида».
clean_json_raw(жадная регулярка) удалена. Вместо неёModelJsonError+extract_codes(text): сканер сбалансированных объектов взят из дерева —from distiller import _iter_json_objects, третья копия не написана. Коды собираются из ВСЕХ целых объектов ответа, по порядку, без дублей. Пусто не возвращается никогда: либо коды, либоModelJsonErrorс причиной (обрезан / объект без кодов) и с концом ответа в тексте ошибки — оператору видно, что именно сказала модель.classify_fact: пустой ответ модели и ответ без цифровых кодов больше НЕ уезжают в базу кодом10.1сis_reclassified = 1— печатается[ОТКАЗ]и возвращаетсяNone, факт остаётся со своим прежним кодом и попадёт в следующий прогон. Раньше он становился недостижимым для врача навсегда.classify_fact:except ModelJsonErrorстоит ОТДЕЛЬНО и ПЕРВЫМ, до общей ветки. Плюс в общей веткеre.search(r"\b(limit|quota)\b", err)вместо"limit" in err: без границы слова подстрокаlimitнаходится внутриdeLIMITer, и обрыв ответа читался как лимит квоты. Побочно замер показал, что прежняя тройка условий ПРОПУСКАЛА настоящие ошибки квоты видаQuota exceeded for quota metricиYou exceeded your current quota(в них нет ни429, ниlimit, ниexhausted) — теперь они распознаются какRETRY.classify_fact:dict.fromkeysвместоlist(set(...))— один и тот же ответ модели давал разную строку кодов от прогона к прогону (порядокsetзависит от хеш-сида процесса), то есть diff базы был нечитаем.main: флагaborted. Прогон, оборванный лимитом ключей на первом же факте, больше не печатаетBASE RECLASSIFIED SUCCESSFULLY, а честно говоритПРОГОН ОБОРВАН на N из M. СтрокаВсе ключи в лимитесохранена дословно — на неё опираетсяtest_distill_scripts_safety [6].
- Своя копия
clean_json_rawудалена, разбор один на два скрипта:from reclass import ModelJsonError, extract_codes.import jsonубран — после правки он остался без единого использования (ruff F401). classify_video:except ModelJsonErrorотдельно — печатает[ОТКАЗ РАЗБОРА]с причиной и прекращает перебор ключей. Замер: было 10 вызовов модели МОЛЧА на один протокол, стало 1 вызов с названной причиной. Протокол остаётся вvideos.txt, повторный прогон разметит его нормально.
Не изменён (см. §0.6: заявленной находки в живом файле нет). md5 тот же:
d4433a372a...(md5, укорочен).
[1] болтовня после объекта разбирается (6 форм ответа x reclass + 2 x videosi),
плюс ДВА сквозных прогона: reclass.main переклассифицирует все факты и сохраняет
прежние коды в category_code_prev; videosi.main реально ЗАПИСЫВАЕТ протокол в
базу с телом и подтверждённым провенансом (раньше не записывался вовсе).
[2] обрыв = громкий отказ с причиной; отдельно 6 проверок транспортной ветки
(delimiter -> не лимит; 429/quota/TPM limit -> RETRY); сквозной прогон:
ни один код не перезаписан, ротации ключей нет, ложного «Все ключи в лимите» нет,
цена ограничена MAX_FAILS_PER_FACT на факт, успехом прогон не отчитался.
[3] несколько объектов разбираются все (2, 3 объекта, дубли, образец X.X.X из
промпта, объект в объекте) + стабильность порядка на 5 наборах по 4 кода.
[4] источник разбора один: подмена reclass._iter_json_objects шпионом видна и в
videosi (это поведенческая проверка, а не сравнение текста).
[5] пустой ответ / объект без кодов не хоронят факт кодом-заглушкой (сквозной
прогон: 10.1 в базе нет, is_reclassified остался 0).
[6] настоящий лимит ключей: прогон останавливается и не врёт об успехе.
[7] md5 боевых баз до и после.
Сети нет ни в одной проверке, боевые базы не открываются вообще.
Драйвер: _sabotage_json_scan.py (9 диверсий) и _sabotage_json_scan2.py (S4 на
5 разных хеш-сидах). Ни одна диверсия не ломает файл синтаксически — драйвер
компилирует файл после правки и отбросил бы такую как недоказательную.
| # | диверсия | провалено проверок | поймана |
|---|---|---|---|
| S1 | вернуть жадный разбор до последней скобки | 22 | да |
| S2 | вернуть "limit" in err без границ слова |
4 | да |
| S3 | убрать отдельную ветку except ModelJsonError |
4 | да |
| S4 | вернуть list(set(...)) вместо dict.fromkeys |
1..3 | да |
| S5 | вернуть заглушку 10.1 на пустом ответе |
8 | да |
| S6 | брать коды только из ПЕРВОГО объекта | 6 | да |
| S7 | убрать честный итог оборванного прогона | 2 | да |
| S8 | videosi снова молча перебирает все 10 ключей |
3 | да |
| S9 | ТРЕТЬЯ копия разбора в videosi (поведение то же) |
3 | да |
S4 отдельно: порядок set зависит от хеш-сида процесса, поэтому «поймалась
однажды» здесь не доказательство. Замер на PYTHONHASHSEED = 7, 14, 21, 28, 35:
общее число провалов 1, 2, 3, 2, 2 — и проверка «порядок кодов не переставляется
(5 наборов из 4 кодов)» падала во ВСЕХ пяти прогонах. Именно её и стоит считать
сторожем; две другие проверки на этой диверсии флакуют по своей природе.
S9 — единственная диверсия, которую нельзя поймать сравнением ответов: копия разбора ведёт себя так же. Её ловит подмена сканера шпионом.
md5 до правок (исходное состояние лида):
reclass.py fa1252af96...(md5, укорочен)
videosi.py 230f8b5e1c...(md5, укорочен)
md5 после правок (моё конечное состояние):
reclass.py 10cd690acc...(md5, укорочен)
videosi.py e414198aee...(md5, укорочен)
md5 после ВСЕХ 9 диверсий и восстановления: те же
reclass.py 10cd690acc...(md5, укорочен)
videosi.py e414198aee...(md5, укорочен)
test_json_scan.py 725ea012d2...(md5, укорочен)
Диверсий в боевом коде не осталось, .bak удалены (проверено os.path.exists).
Боевые базы побайтово те же: stomat_wiki.db 2207af7649...(md5, укорочен),
stomat_archive.db addae58b92...(md5, укорочен) — сверено и в моём тесте [7].
Файлов wiki_backup_*.db в корне не появилось (все прогоны — во временных каталогах,
каталоги удалены).
test_json_scan.py PASSED 92 FAILED 0 (новый)
test_distill_scripts_safety.py PASSED 95 FAILED 0
test_video_import.py PASSED 61 FAILED 0
test_distill_pipeline.py PASSED 145 FAILED 0
test_import_safety.py PASSED 444 FAILED 0
test_isolation.py PASSED 198 FAILED 0
run_all_tests.py не запускался (md5-охрана даёт ложную тревогу, пока рядом пишут
другие агенты). Числа test_import_safety (439 -> 444) и test_isolation
(196 -> 198) выросли за время работы — это соседние ланы добавляли файлы, не я.
- Боевого прогона нет и быть не может: бот и ключи на другой машине. Все ответы
модели — заглушки. Форму реальной болтовни Gemma 3 27B я не наблюдал, взял
9 реалистичных вариантов; какая доля живых ответов действительно содержит
скобу в хвосте, замерить нечем (в
distiller.logза прогон 2026-02-17..19 провалов парсинга 0, но это лог ДРУГОГО скрипта с другой моделью). videosiна ПУСТОМ ответе модели (textпустой илиNone) по-прежнему перебирает все 10 ключей молча:ModelJsonErrorтам не возникает, потому что до разбора дело не доходит. Это транспортная ветка, я её не трогал — правка в §6 «для лида».- Верхнего предела на число кодов нет: промпт требует «не более 5», и ответ из трёх объектов теперь даёт объединение кодов. На живых ответах это не замерено; предложение — в §6.
reclass.pyзависит от ПРИВАТНОГО имениdistiller._iter_json_objects. Пока я работал,distiller.pyправил другой агент, и функция переехала с 444 строки на 423 (md5e6af576b...->695363bf...). Импорт выжил, но переименование её в чужой лане уронитreclass/videosiНА ИМПОРТЕ. Тест[4]это ловит громко, но «ловит» — не то же, что «не сломается».- Тайминги (13 вызовов / 26 с против 18 вызовов) замерены на
SLEEP_* = 0, то есть это арифметика по константам, а не хронометраж живого прогона.
distiller.py: третья копия жадного разбора.clean_json_string(distiller.py:431..441на момент моего замера, файл с тех пор двигался) режет от первой{доrfind('}')— тот же дефект. Вparse_factsон прикрыт: сначала пробуется ответ как есть, потомclean_json_string, а при провале работает спасение через_iter_json_objects. То есть в distiller дефект не смертелен, но копия остаётся. Предложение:clean_json_stringоставить только для снятия- Публичное имя сканеру.
_iter_json_objectsтеперь импортируют два внешних модуля. Прошу переименовать вiter_json_objectsи оставить_iter_json_objects = iter_json_objectsдля совместимости — тогда чужая правка приватного имени не уронит скрипты дистилляции. Пока имя приватное, править его нельзя без правкиreclass.py. - Где по-хорошему должен жить
extract_codes. Я положил его вreclass.py, аvideosi.pyимпортирует оттуда: это в границах моих файлов. Правильное место — рядом со сканером (distiller.py) или в маленьком общем модуле. Как толькоtaxonomy.py-подобный общий модуль для разбора появится, перенести туда. FALLBACK_CODEразошёлся.videosi.FALLBACK_CODE == "10.1", аtaxonomy.FALLBACK_CODE == distiller.FALLBACK_CODE == "10.1.1"(test_taxonomy_single.py:115). Два разных «кода незнания» в одной базе: экспорт и рубрикатор фильтруют по одному, а видео-импорт пишет другой. Не мой лан (taxonomy правит сосед), но это разойдётся в выдаче врачу.import_videos.pyи копия органайзера. Живой файл — обезвреженная заглушка, находка ruffF841 title_candidateотносится кstomchat/import_videos.py:94, то есть к ПОЛНОЙ копии репозитория внутри репозитория. Пока эта копия лежит внутри дерева, любой линтер и любой обход дают удвоенные и ложные результаты. Предложение: вынести копию за пределы репозитория (в.gitignoreона уже есть, но линтеры иruffигнор.gitignoreне читают без--respect-gitignore).videosi.classify_video, транспортная ветка. Пустой ответ модели и любая транспортная ошибка по-прежнему обрабатываютсяexcept Exception: continueмолча: замер — 10 вызовов на один протокол и ни одной строки в журнале, при том что протокол всё равно не записывается. Минимальная правка: печатать по одной строке на ключ (ключ N: <класс ошибки>) и отделить «модель ответила пустотой» от «ключ не ответил». Я не делал: это уже не разбор JSON, а политика ротации, и рядом идёт лан по ключам.- Предел на число кодов. Промпт в обоих скриптах требует «не более 5 кодов», в
коде предела нет. После моей правки ответ из нескольких объектов даёт объединение
кодов, так что теоретически кодов может стать больше 5 (на замеренных 9 ответах —
максимум 3). Если предел нужен, он должен стоять в одном месте вместе с
clean_codes. import timeне используется ни вreclass.py:15, ни вvideosi.py:16(ruff F401). Не трогал, чтобы не расширять diff косметикой.