Skip to content

Latest commit

 

History

History
316 lines (261 loc) · 26.9 KB

File metadata and controls

316 lines (261 loc) · 26.9 KB

Лана: жадный разбор JSON от модели (reclass.py, videosi.py, import_videos.py)

Файлы лана: reclass.py, videosi.py, import_videos.py, test_json_scan.py (создать). Пишется инкрементально по ходу работы. Git не трогался вообще.

0. Что замерено (живые данные, сети нет, боевые базы не открывались)

Скрипты замеров: _measure_json_scan.py, _measure_json_scan2.py, _measure_json_scan3.py.

0.1 Задание подтверждено: копии не расходятся, ломаются обе одинаково

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)

0.2 Правильная реализация — distiller.py:444, а НЕ 569

Задание и разведка называют строку 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), это третья копия того же класса, но файл не мой — см. «для лида».

0.3 Цена дефекта РАЗНАЯ в двух файлах, и в задании она названа мягче, чем есть

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_KEYS10. Болтовня после объекта -> classify_video перебирает все 10 ключей МОЛЧА (except Exception: continue) и возвращает FALLBACK_CODE = '10.1', а в main это ветка «НЕ ЗАПИСАН»: видео- протокол в базу не попадает вовсе. Для врача: разбора нет в рубрикаторе, и никто не видит причины — 10 вызовов сгорели без единой строки в журнале.

0.4 НАХОДКА СВЕРХ ЗАДАНИЯ: обрыв ответа обрывает ВЕСЬ прогон reclass с ложным диагнозом

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.

0.5 НАХОДКА СВЕРХ ЗАДАНИЯ: пустой ответ модели хоронит факт кодом-заглушкой

Замер (_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 вызовами и молчанием.

0.6 import_videos.py: заявленной находки в моём файле НЕТ

Задание: «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. Не трогаю — косметика вне лана, см. «для лида».

1. Что изменено

reclass.py

  1. clean_json_raw (жадная регулярка) удалена. Вместо неё ModelJsonError + extract_codes(text): сканер сбалансированных объектов взят из дерева — from distiller import _iter_json_objects, третья копия не написана. Коды собираются из ВСЕХ целых объектов ответа, по порядку, без дублей. Пусто не возвращается никогда: либо коды, либо ModelJsonError с причиной (обрезан / объект без кодов) и с концом ответа в тексте ошибки — оператору видно, что именно сказала модель.
  2. classify_fact: пустой ответ модели и ответ без цифровых кодов больше НЕ уезжают в базу кодом 10.1 с is_reclassified = 1 — печатается [ОТКАЗ] и возвращается None, факт остаётся со своим прежним кодом и попадёт в следующий прогон. Раньше он становился недостижимым для врача навсегда.
  3. 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.
  4. classify_fact: dict.fromkeys вместо list(set(...)) — один и тот же ответ модели давал разную строку кодов от прогона к прогону (порядок set зависит от хеш-сида процесса), то есть diff базы был нечитаем.
  5. main: флаг aborted. Прогон, оборванный лимитом ключей на первом же факте, больше не печатает BASE RECLASSIFIED SUCCESSFULLY, а честно говорит ПРОГОН ОБОРВАН на N из M. Строка Все ключи в лимите сохранена дословно — на неё опирается test_distill_scripts_safety [6].

videosi.py

  1. Своя копия clean_json_raw удалена, разбор один на два скрипта: from reclass import ModelJsonError, extract_codes. import json убран — после правки он остался без единого использования (ruff F401).
  2. classify_video: except ModelJsonError отдельно — печатает [ОТКАЗ РАЗБОРА] с причиной и прекращает перебор ключей. Замер: было 10 вызовов модели МОЛЧА на один протокол, стало 1 вызов с названной причиной. Протокол остаётся в videos.txt, повторный прогон разметит его нормально.

import_videos.py

Не изменён (см. §0.6: заявленной находки в живом файле нет). md5 тот же: d4433a372a...(md5, укорочен).

Новый тест test_json_scan.py — 92 проверки, все поведенческие

[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 боевых баз до и после.

Сети нет ни в одной проверке, боевые базы не открываются вообще.

2. Саботаж

Драйвер: _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 — единственная диверсия, которую нельзя поймать сравнением ответов: копия разбора ведёт себя так же. Её ловит подмена сканера шпионом.

3. Восстановление

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

4. Прогоны наборов (после всех правок и восстановления)

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) выросли за время работы — это соседние ланы добавляли файлы, не я.

5. Что осталось непроверенным

  • Боевого прогона нет и быть не может: бот и ключи на другой машине. Все ответы модели — заглушки. Форму реальной болтовни 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 (md5 e6af576b... -> 695363bf...). Импорт выжил, но переименование её в чужой лане уронит reclass/videosi НА ИМПОРТЕ. Тест [4] это ловит громко, но «ловит» — не то же, что «не сломается».
  • Тайминги (13 вызовов / 26 с против 18 вызовов) замерены на SLEEP_* = 0, то есть это арифметика по константам, а не хронометраж живого прогона.

6. Для лида (правки вне моих файлов, текстом)

  1. distiller.py: третья копия жадного разбора. clean_json_string (distiller.py:431..441 на момент моего замера, файл с тех пор двигался) режет от первой { до rfind('}') — тот же дефект. В parse_facts он прикрыт: сначала пробуется ответ как есть, потом clean_json_string, а при провале работает спасение через _iter_json_objects. То есть в distiller дефект не смертелен, но копия остаётся. Предложение: clean_json_string оставить только для снятия
  2. Публичное имя сканеру. _iter_json_objects теперь импортируют два внешних модуля. Прошу переименовать в iter_json_objects и оставить _iter_json_objects = iter_json_objects для совместимости — тогда чужая правка приватного имени не уронит скрипты дистилляции. Пока имя приватное, править его нельзя без правки reclass.py.
  3. Где по-хорошему должен жить extract_codes. Я положил его в reclass.py, а videosi.py импортирует оттуда: это в границах моих файлов. Правильное место — рядом со сканером (distiller.py) или в маленьком общем модуле. Как только taxonomy.py-подобный общий модуль для разбора появится, перенести туда.
  4. FALLBACK_CODE разошёлся. videosi.FALLBACK_CODE == "10.1", а taxonomy.FALLBACK_CODE == distiller.FALLBACK_CODE == "10.1.1" (test_taxonomy_single.py:115). Два разных «кода незнания» в одной базе: экспорт и рубрикатор фильтруют по одному, а видео-импорт пишет другой. Не мой лан (taxonomy правит сосед), но это разойдётся в выдаче врачу.
  5. import_videos.py и копия органайзера. Живой файл — обезвреженная заглушка, находка ruff F841 title_candidate относится к stomchat/import_videos.py:94, то есть к ПОЛНОЙ копии репозитория внутри репозитория. Пока эта копия лежит внутри дерева, любой линтер и любой обход дают удвоенные и ложные результаты. Предложение: вынести копию за пределы репозитория (в .gitignore она уже есть, но линтеры и ruff игнор .gitignore не читают без --respect-gitignore).
  6. videosi.classify_video, транспортная ветка. Пустой ответ модели и любая транспортная ошибка по-прежнему обрабатываются except Exception: continue молча: замер — 10 вызовов на один протокол и ни одной строки в журнале, при том что протокол всё равно не записывается. Минимальная правка: печатать по одной строке на ключ (ключ N: <класс ошибки>) и отделить «модель ответила пустотой» от «ключ не ответил». Я не делал: это уже не разбор JSON, а политика ротации, и рядом идёт лан по ключам.
  7. Предел на число кодов. Промпт в обоих скриптах требует «не более 5 кодов», в коде предела нет. После моей правки ответ из нескольких объектов даёт объединение кодов, так что теоретически кодов может стать больше 5 (на замеренных 9 ответах — максимум 3). Если предел нужен, он должен стоять в одном месте вместе с clean_codes.
  8. import time не используется ни в reclass.py:15, ни в videosi.py:16 (ruff F401). Не трогал, чтобы не расширять diff косметикой.