Обновления

Что мы выкатили — простым языком · всего постов: 27

← Дашборд
инфраструктура

Письма рассылки: «позвоните» вместо «напишите» + чистка адресов

В письмах рядом с телефоном теперь «позвоните» вместо «напишите» — и в первом письме, и в напоминанияхПрошли активную рассылку и заменили адреса, по которым письмо уходило не на ту компанию, на проверенныеВ мониторинге рассылки у каждого адреса теперь виден источник и насколько он надёжный

Коротко

Поправили формулировку в письмах и почистили адреса в активной рассылке — чтобы письма шли по верному адресу и звучали правильно.

Формулировка в письмах

Рядом с телефоном было «или напишите +7…», стало «или позвоните +7…». Поправили во всех письмах — и в первом, и в последующих напоминаниях.

Адреса

Прошлись по адресам, на которые сейчас идёт рассылка. По нескольким письмо уходило не на ту компанию — это были адреса, случайно подобранные поиском (например, почта совсем другой организации, совпавшей по названию). Такие адреса убрали и заменили на проверенные; по ним рассылка стартовала заново с верного адреса.

Откуда берётся адрес — теперь видно

В мониторинге рассылки у каждого адреса подписано, из какого источника он взят и насколько надёжный. Слабые адреса видно сразу — на них можно смотреть в первую очередь.

новая возможность

Рассылка: видно ответы сразу, и письма доходят до адресата

Появился экран рассылки (кнопка «📬 Рассылка») — видно, кому ушло письмо, по какому делу, какого типа и был ли ответОтветы ловим сразу и помечаем тон — интерес, отказ или нейтрально, чтобы быстро реагировать на заинтересованныхЕсли адрес оказался нерабочим, письмо автоматически уходит на запасной адрес той же компании, а заведомо мёртвые адреса отсеиваем ещё до отправки

Коротко

Рассылка пошла, и теперь весь процесс виден: кому ушло, какое письмо и, главное, кто ответил. Заинтересованные ответы подсвечиваем, чтобы реагировать сразу, пока контакт «тёплый».

Видно всю рассылку

По кнопке «📬 Рассылка» открывается отдельный экран. По каждому письму — компания, дело, тип письма (под стадию) и статус. Сразу видно, сколько ушло и по каким делам. Можно открыть и сам текст письма, которое получил адресат.

Ответы — сразу и с тоном

Как только приходит ответ, он попадает в общий список и помечается по тону — интерес, отказ или нейтрально. На заинтересованные реагируем быстро: чем раньше ответить, тем выше шанс на разговор.

Письма доходят до адресата

Часть адресов в базе оказалась нерабочей — компания сменила почту или домен закрыт. Теперь такие случаи ловим автоматически: если письмо не дошло, оно сразу уходит на запасной адрес той же компании, а заведомо мёртвые адреса отсеиваем ещё до отправки. Плюс там, где письмо шло на общий ящик info@, по возможности переключаем на адрес на домене самой компании — ближе к нужному человеку. Так стараемся, чтобы письмо реально доходило, а не терялось.

новая возможность

Теперь по делу видна дата ближайшего судебного заседания

В карточке дела появился блок «Судебные заседания» — дата, время и зал ближайшего заседания, подтягиваются автоматически из kadЕсли суд перенесёт заседание — подтянется новая дата, а прежние останутся в историиПрошлись по уже принятым делам и заполнили дату там, где она раньше не показывалась

Коротко

По каждому делу теперь видно, когда ближайшее судебное заседание — дата, время и зал. Открываете карточку дела — и сразу видите, без захода в kad.

Где смотреть

В карточке дела появился блок «Судебные заседания». Сверху — ближайшая дата, ниже — история, если заседание уже переносили.

Дату берём прямо из карточки дела на kad и обновляем на каждом обходе. Если суд перенесёт заседание — новая дата подтянется сама, а прежняя останется в истории, чтобы было видно, как двигали.

Заполнили по всем делам

Заметили, что по части дел дата не показывалась, хотя в kad она есть. Поправили и прошлись по уже принятым делам — заполнили задним числом. Теперь дата стоит везде, где суд её назначил.

новая возможность

Письма обновили по вашим правкам; по возвратам система сама определяет причину

Учли ваши правки по письмам — подпись, возврат без госпошлины, обездвижка отдельно под каждое основание, СРО по всемПо обездвижке теперь отдельное письмо под каждую причину (5 вариантов) — собрано на схеме для чтенияПо возвратам система сама вычитывает из определения суда настоящую причину — точнее письма и отсев дел, которые нам не подходят

Коротко

Прошлись по вашим правкам к письмам и кое-что улучшили по возвратам. Всё для чтения — на схеме путь дела → письмо.

Что поправили в письмах

По вашим замечаниям:

  • Подпись — везде «Иван Блинов, руководитель практики реструктуризации задолженности и банкротства», от ЮА «Правый Берег». Контакт — офисный телефон.
  • Возврат — убрали из предложения госпошлину, оставили только депозит на вознаграждение управляющего.
  • СРО — пишем по всем, не только по нашей: название СРО берём прямо из определения по конкретному делу.

Обездвижка — отдельное письмо под каждую причину

Вы отметили, что коллекция была неполной — был только «не внесён депозит». Теперь под каждое основание обездвижки свой текст:

  • не уплачена госпошлина;
  • не внесён депозит;
  • недостатки по форме и документам;
  • нет публикации уведомления о намерении на ЕФРСБ;
  • не направлена копия заявления.

Логика предложения: если дело в деньгах (госпошлина/депозит) — «оплатим ровно это, а вы предлагаете нашего управляющего»; если в документах — берём сопровождение на себя и доводим заявление до принятия. Каждое письмо — отдельная карточка на схеме, открывается полный текст.

По возвратам — определяем настоящую причину

Раньше причину возврата мы ловили по набору типовых формулировок — и часто промахивались. Теперь система сама вычитывает причину прямо из определения суда по каждому делу.

Что это даёт:

  • точнее письмо — называем заявителю реальную причину, по которой его заявление вернули;
  • отсев лишнего — система отделяет дела, по которым писать не нужно: где заявитель сам отозвал заявление; где это вообще не возврат, а дело по ошибке попало в список (на самом деле его приняли); где речь про включение в реестр, а не про банкротство.

Это та же база, что вы просили проверить — заодно стало видно, какие дела в ней «не те». Разберём при созвоне.

Что дальше — от вас

  • посмотреть обновлённые письма на схеме и дать правки, если что;
  • два открытых решения: что считаем «возвратом» и нужно ли письмо при отказе в принятии заявления.
новая возможность

Письма для рассылки готовы — можно читать; собраны контакты; уточнили, когда отправляем

Тексты писем написаны по всем веткам — должникам и кредиторам-заявителям — и собраны на одной схеме для чтенияПисьма стали адресными — обращение к директору по имени, «работаем в вашем округе», от лица ЮА «Правый Берег»Уточнили правило — пишем только когда есть определение суда, а не просто когда дело появилосьПо большинству должников собраны контакты для отправки

Коротко

Большой шаг по рассылке — письма написаны, и их можно прочитать.

Письма под каждый случай. Подготовили тексты на все ветки:

  • должнику — когда заявление приняли: обычный вариант, вариант без указанной СРО и отдельный — когда банкротит налоговая;
  • кредитору-заявителю — когда его заявление оставили без движения (обездвижка) или вернули.

К каждому письму — два коротких напоминания (на 3-й и 7-й день), если не ответили. Как только адресат ответил — больше не пишем.

Всё это собрано на одной наглядной схеме: путь дела → письмо. Наведите на ветку — подсветит всю цепочку; кликните на письмо — откроется полный текст.

Письма стали адресными

То, что вы просили — чтобы письмо было «не безликим»:

  • обращение к директору по имени;
  • «работаем в вашем регионе, знакомы с вашим арбитражом» (округ берём из номера дела);
  • от лица ЮА «Правый Берег»;
  • у кредиторских писем — предложение под конкретную причину (что именно не так с заявлением), и отдельно — что нам может быть интересен выкуп долга.

Когда отправляем

Уточнили правило: пишем не по факту, что дело просто появилось, а только когда есть определение суда — приняли заявление (→ пишем должнику) либо оставили без движения / вернули (→ пишем заявителю). Пока определения нет — ждём, не пишем.

Контакты и данные по делу

По большинству должников (примерно 9 из 10) собраны контакты для отправки.

Чтобы письма и разговор были предметнее, по каждому делу готовим дополнительную картину о должнике — финансы, другие судебные дела, сколько кредиторов уже заявили о намерении его банкротить.

Что дальше — от вас

  • прочитать тексты (ссылка на схему выше) и дать правки;
  • финальная подпись и контактный телефон для писем;
  • пара решений: что считаем «возвратом» и нужно ли письмо в случае отказа в принятии заявления.
инфраструктура

Дела, где уже введена процедура, больше не путаются с ранней стадией

Дела, где суд уже ввёл процедуру (наблюдение/конкурс/реализация), уходят из рабочего списка в отдельную папку «В процедуре»Такие дела исключаются из рассылки — не напишем «у вас ещё есть время» тому, кто уже в конкурсеРазово почистили базу — перенесли 48 дел (18 конкурс, 26 наблюдение, 4 реализация)

Коротко

Раньше система в редких случаях не распознавала, что по компании суд уже ввёл процедуру (наблюдение или конкурс — то есть её уже признали банкротом), и такое дело продолжало висеть в списке как будто оно на ранней стадии. Для рассылки это критично: нельзя писать «у вас ещё есть время на защиту» тому, кто уже в конкурсе.

Добавили распознавание этого момента — такие дела теперь автоматически уходят из рабочего списка в отдельную папку «В процедуре» (видно на дашборде) и исключаются из рассылки.

Разово почистили базу: перенесли 48 дел (18 конкурс, 26 наблюдение, 4 реализация имущества). Дальше это происходит само.

новая возможность

Рассылка: можем писать всем, как только против компании подали на банкротство

Можем писать всем компаниям сразу, как только против них подали заявление о банкротстве (а не только по делам, где уже назначено заседание)Контакты по таким компаниям собрали — адреса есть примерно у 93%{'Готов черновик письма': 'по каждой компании разбираем именно ЕЁ ситуацию и предлагаем помощь'}Отклики с сайта падают в мини-CRM (доску по стадиям)

Коротко

  1. Раньше рассылку готовили только по делам, где уже назначено заседание. Теперь можем писать всем компаниям сразу, как только против них подали заявление о банкротстве — на самой ранней стадии.
  2. Контакты по таким компаниям собрали — адреса есть примерно у 93%.
  3. Готов черновик письма — по каждой компании разбираем именно ЕЁ ситуацию и предлагаем защиту. Пример прислал тебе отдельно.

В чём смысл

Самый ранний момент — лучший, чтобы предложить помощь: на компанию только что подали на банкротство, она ещё не нашла юриста. Мы ловим это сразу и пишем адресно.

Про письмо

Не продаём в лоб. По каждой компании письмо объясняет её ситуацию из наших данных: кто подал на банкротство, какие у компании обороты и активы, что реально можно сделать на этой стадии (оспорить требования, повлиять на выбор управляющего, защитить активы). Дальше — предложение разобрать на короткой консультации без обязательств. Подпись — «Правый Берег», с 2008 года.

Отклики

Кто ответит — заявка сразу падает в мини-CRM: доска по стадиям (новый → консультация → встреча → в работе → выиграно/проиграно). Ни один отклик не потеряется.

Что нужно от тебя, чтобы запустить

  1. Прочитать пример письма (прислал) — ок или поправить формулировки.
  2. Подпись и контакт для ответа (имя + телефон/почта).
  3. Кому слать: всем, против кого подали на банкротство, или с исключениями (например, не писать, когда банкротит банк или лизинг)?

Как пришлёшь — добиваем и запускаем.

новая возможность

Контакты по свежим делам — почти полные (~92%) и собираются сами

По делам в работе контакты должников есть у ~92% (было ~40%)Подключил DataNewton как источник контактов — добирает тех, кого не находят остальныеКонтакты теперь собираются автоматически каждые 6 часов — база больше не устаревает

Коротко

Раньше контакты собирались разово, и по свежим делам их почти не было — писать, по сути, некому. Теперь:

  1. Контакты по делам в работе — ~92% должников (было ~40%).
  2. Подключил DataNewton как источник контактов — он закрыл тех, кого остальные источники не находили.
  3. Собирается само — каждые 6 часов новые дела автоматически дополняются контактами. База больше не «протухает».

Что было

Контакты собрались одним прогоном в середине мая. С тех пор появились сотни новых дел — и контактов по ним почти не было. Для рассылки это главный блокер: списки старые и дырявые, писать некому.

Что сделал

  1. Запустил автоматический сбор. Каждые несколько часов бот берёт новые дела и сам ищет контакты должников сразу по нескольким источникам — госреестры, справочники, поиск.
  2. Подключил DataNewton как источник контактов. Раньше мы брали из него только проверку — что адрес реально принадлежит компании. Теперь он отдаёт и сами контакты, и закрывает как раз тех должников, кого остальные источники не находят: по свежим делам добрал примерно 8 из 10 недостающих.

Что в итоге

  • Дела в работе: ~92% должников с контактами (было ~40%).
  • Новые дела дополняются автоматически, каждые 6 часов — без ручного запуска.
  • Можно собрать контакты и точечно — кнопкой «Обогатить» в карточке (она теперь тоже использует DataNewton).

Что это значит для рассылки

По свежим делам почти всегда есть, кому писать — главный блокер по объёму снят. Списки теперь свежие и почти полные.

Что нужно от тебя: подтвердить финальные фильтры по 4 категориям (кому и что пишем) — после этого можно готовить отправку.

новая возможность

Новая страница «Процессы» + обездвиж-дела больше не теряются через 30 дней

На дашборде появилась кнопка «📊 Процессы» — все процессы бота нарисованы схемами, простым языкомОбездвиж-дела теперь мониторятся дольше — поймаем позднее принятие (придёт СРО-алерт) или возврат (уйдёт в возвраты)Финансовые цифры в карточке всегда берутся за последний поданный в ФНС год

Коротко

Три вещи.

  1. Сделал для тебя страницу со схемами — как бот находит дела, следит за ними и готовит рассылку. Открывается с дашборда, кнопка «📊 Процессы».
  2. Обездвиж-дела больше не «протухают». Раньше через 30 дней дело снималось с мониторинга — даже если обездвижка ещё не разрешилась. Теперь следим дальше — до ~90 дней или пока не появится принятие/возврат.
  3. Выручка — довёл до конца: все финансовые цифры в карточке всегда за последний поданный год.

1. Страница «Процессы»

Ты говорил, что в голове тяжело держать всю механику. Нарисовал — 7 схем простым языком: поиск дел, мониторинг, извлечение СРО, обездвиж, возвраты, заявители, рассылка. Плюс короткий список «что нужно от тебя».

Где. На дашборде вверху новая кнопка «📊 Процессы» (рядом с «Обновления»).

Как читать. Цвет = смысл: зелёный — дело движется, серый — отсеяли, бирюзовый — тебе придёт уведомление. Пунктир = ещё не запущено (например, заявители и автоматическая рассылка — пока в работе, и на схеме это честно отмечено пунктиром, чтобы ты не принял план за готовое).

2. Обездвиж-дела не теряются

Что было. Бот снимает дело с мониторинга через 30 дней без движения («устаревание»). Но обездвижка часто разрешается позже: заявитель устраняет недостатки, и принятие приходит через несколько недель. Дело к этому времени уже снято с мониторинга — и принятие (а значит, и алерт по СРО) мы бы пропустили.

Что сделали. Пока у дела есть неразрешённая обездвижка, продолжаем следить за ним и после 30 дней (проверяем раз в 12 часов). Как только приходит:
- принятие — дело продолжает обрабатываться как принятое, и прилетает алерт по нашим правилам (наша СРО / СРО не указана);
- возврат — дело уходит в категорию возвратов.

Дело перестаёт «висеть», как только обездвижка разрешилась (или если за ~3 месяца так ничего и не произошло — тогда отпускаем).

Что увидишь. Если дело было в обездвиже и через пару недель там появилось принятие с нашим СРО — придёт уведомление, как по обычному принятию. Раньше по «долгим» делам оно могло не прийти.

3. Выручка — последний поданный год

В прошлый раз починили саму выручку. Теперь довёл до конца: финансовая раскладка в карточке — выручка/активы, прибыль, дебиторка, основные средства и сравнение с прошлым годом — привязана к одному и тому же последнему поданному в ФНС году, а не к разным. Если последний год ещё не сдан — берём предыдущий. (Это про то, как показываются цифры; переотбора дел тут нет — про него ниже.)

Что дальше

  • Обездвиж → письма. Размечаем 100 дел вместе, чтобы бот точно определял основание обездвижки. Дальше — шаблоны писем под каждое основание (должник / кредитор — письма разные).
  • Выручка — отбор по свежим данным. На созвоне решаем: отсеивать ли дела по актуальным данным ФНС. Тот же СТРОЙДЕМСЕРВИС — по свежей выручке 2025 (68,8 млн) и активам (414 млн) порог не проходит; в списке он только потому, что когда-то прошёл по устаревшим цифрам 2024. Это аргумент отбирать по свежим данным — но сначала проверим всю базу, чтобы не выкинуть нужное.

Технические детали

Issues, PRs, commits - **#311** — Denis-facing флоучарты. `docs/ПРОЦЕССЫ.md` (7 Mermaid-схем, источник правды) → генератор `scripts/render_processes_html.py` → `src/web/процессы.html`; роут `GET /processes` + nav-кнопка в `dashboard.html`. Рендер провалидирован в headless Chrome (8/8 диаграмм). PR #315, commit `b31da62`. Веб-сервис перезапущен на проде, `/processes` отдаёт 200. - **#310** — extended-watch для неразрешённой обездвижки. Общий предикат `_UNRESOLVED_OBEZDVIZHKA_EXISTS` (граница `_OBEZDVIZHKA_WATCH_DAYS=90` от `first_seen`) в `get_cases_for_monitoring` (новая OR-ветка, 12h) И `mark_stale_cases` (`AND NOT …`). Переходы (`mark_obezdvizhka_resolved` на принятии/возврате/отказе/ghost) уже работали — чинили только устаревание. 4 DB-теста. PR #317, commit `16842ff`. - **#312** — `_enrich_with_pnl` привязан к `_latest_bfo(bfo_data)` вместо `bfo_data[0]` (P&L/динамика не рассинхронятся с заголовком при переупорядочивании /bfo). Квалифицирующие поля уже использовали `max(period)`. Эмпирический пробинг bo.nalog (ИНН 5001063186): `period` = строка-год, latest-first. PR #316, commit `63024da`. Глубокий фикс ложно-отрицательных = флип `use_unified_gate` (отложен, нужен аудит). - Все четыре (#309 grade-sheet, #310, #311, #312) — review-цепочка Opus → Codex → Gemini → mechanical green.
исправление

Починили две вещи, которые ты заметил: обездвиж снова обновляется + правильная выручка

Список обездвиженных дел снова пополняется (был заморожен с 20 мая) — и вернули 23 пропущенных за это время делаВыручка в карточках и алертах теперь свежая, из бо.nalog с годом отчёта, а не устаревшая оценка

Коротко

Ты подсветил две вещи на этой неделе — обе починили.

  1. Обездвиж не обновлялся с 19–20 мая. Снова работает, и вернули все дела, которые система поймала, но не показала за время заморозки.
  2. Выручка по части компаний была неправильная. Теперь берём свежие данные из бо.nalog и показываем с годом отчёта.

1. Обездвиж снова обновляется

Что было. С 20 мая в списке обездвиженных дел не появлялось ничего нового — 12 дней тишины, хотя дела в суде шли.

Почему. Раньше система сохраняла обездвиж только когда в определении совпадали сразу два признака (госпошлина и ещё одно основание вместе). Но большинство реальных обездвижек имеют только одно основание — только депозит, или только госпошлина, или только полномочия. Они проходили мимо. До 20 мая это маскировалось временным режимом «сохранять всё»; когда он выключился, список и замёрз.

Что сделали. Теперь сохраняем каждое определение об оставлении без движения, а основание (госпошлина / депозит / полномочия / неполный пакет / обоснование) проставляем тегом. Защита от «ложных» обездвижек (определения об отложении и т.п.) осталась на месте.

Что увидишь. Список снова пополняется. Плюс мы вернули 23 дела, которые система поймала за период заморозки, но не показала: 12 по госпошлине, 10 по полномочиям, 1 по обоснованию.

2. Правильная выручка

Что было. У части компаний в карточке и в Telegram-алерте выручка не совпадала с реальной. Пример из твоего разбора — СТРОЙДЕМСЕРВИС: показывалось 315 млн, а на самом деле выручка за 2025 — 68,8 млн (упала с 301,8 млн за 2024).

Почему. Для таких дел подтягивалась цифра из старой архивной базы (без года), а свежие данные из бо.nalog не загружались.

Что сделали. Добавили ежедневное обновление финансов из бо.nalog. Теперь в карточке и в алерте — свежая выручка с годом отчёта (например, «68,8 млн (2025)»). А если по компании свежих данных пока нет, цифра честно помечается «≈ оценка» с подсказкой — чтобы ты не принял устаревшее за подтверждённое.

Что увидишь. Актуальную выручку с годом; архивные оценки — с пометкой. Прямо сейчас идёт разовое обновление по всем делам, к утру подтянется по всей базе.

Что дальше

  • Обездвиж: добавлю фильтр по основанию, чтобы ты мог быстро отбирать нужные (готовлю).
  • Выручка: на созвоне обсудим, нужно ли отсеивать компании, у которых свежая выручка упала ниже порога, хотя по архивным данным они проходили (как тот же СТРОЙДЕМСЕРВИС).

Технические детали

Issues, PRs, commits, ops - **#303** обездвижка persist-all — `persist_all_stalled` флаг, оба save-пути сохраняют любое body-gate-passing «оставление без движения», тег по причине (#289); strict-matcher демотнут в advisory. PR #304, commit `28b47b3`. Бэкофилл `backfill_obezdvizhka_persist_all_303.py` вернул 23 строки за 2026-05-21…06-01. - **#306** nalog enrichment — дашборд `COALESCE(de.latest_bfo_revenue, c.revenue)` и `_financials_for_notify` уже предпочитали бо.nalog (с годом), но `debtor_enrichment` для КАД-дел не наполнялся (легаси tax_db-гейт, `use_unified_gate=false`). Новый крон `cron_nalog_enrichment_refresh` (ежедневно 05:20 UTC, инкрементальный `backfill_nalog_enrichment.py`) + разовый bulk-бэкофилл 3942 ИНН + UI-пометка «≈ оценка» на tax_db-фолбэке. PR #307, commit `3f5869c`. - **Отложено (требует аудита):** флип `use_unified_gate: true` — live-гейт, который бы ещё и переквалифицировал дела по свежим данным (СТРОЙДЕМСЕРВИС с 68,8 млн не прошёл бы порог 300 млн выручки).
новая возможность

Pre-КАД discovery: ловим намерения подать на банкротство до того, как дело попадёт в суд

Pipeline теперь видит намерения подать на банкротство за ~15 дней до того, как дело появится в КАДАлерты по этим намерениям активированы, идёт неделя operator-review перед подключением твоего TelegramКаждые 6 часов проверяем top-1001 свежих компаний на fedresurs.ru → фильтр по выручке → активность

TL;DR

С сегодняшнего дня мы видим намерения подать на банкротство (то, что публикуется на fedresurs.ru как «уведомление о намерении обратиться в суд» — ст. 7 №127-ФЗ) за 15 дней до того, как дело реально подаётся в КАД. Это пре-сигнал: должника ещё можно отследить, проверить актив-обязательства, выйти на покупателя/кредитора до того, как процесс попадёт в систему.

Pipeline крутится в шадоу-режиме с 28 апреля — за это время накопил 577 кейсов которые прошли фильтр (выручка / активность / организационная форма). Сегодня переключил в live — алерты по новым намерениям пошли в специальный чат для ревью. После ~недели обкатки подключу этот чат к твоему стриму.

Что было раньше

Pipeline видел банкротное дело только когда оно появлялось в КАД — то есть после того, как заявление принято к производству. Это уже поздновато:

  • К моменту принятия Определения процесс развивается сам по себе. Намерение опубликовано ещё 15 дней назад на fedresurs.ru (это требование ст. 7 №127-ФЗ — кредитор обязан сначала уведомить).
  • Эти 15 дней — окно, когда должник ещё не «загнан в угол». Можно поговорить с публикатором уведомления (часто это потенциальный заявитель — банк, крупный поставщик), понять реальную картину долга, проверить активы должника, выйти на покупателя цессии до того, как дело раздробится между АУ и кредиторами.
  • Мы видели в КАД ~5,500 банкротных дел в год (актуальная статистика). А на fedresurs.ru публикуется ~19,400 намерений в год (стат за 2025). То есть для каждого банкротства публикуется ~3.5 предварительных намерения. 3.5x объём данных, которые мы не использовали.

Что мы сделали

Новый источник. Каждые 6 часов pipeline ходит на fedresurs.ru/backend/companies — это публичный API за анти-DDoS защитой, который возвращает топ-1001 компаний-публикаторов с самой свежей активностью. Сравниваем с предыдущим snapshot — новые компании (которые впервые опубликовали что-то за последние 24h) идут в дальнейшую проверку.

Фильтр. Для каждой новой компании проверяем:
1. Активна ли она по ФНС (status=ACTIVE)
2. Подходит ли её ОКОПФ (юрлица — да, физлица / некоторые формы — нет)
3. Выручка ≥ 300M ₽ или активы ≥ 1B ₽ (тот же порог что в КАД-discovery)
4. Конкретно это намерение — действительно ли «намерение обратиться в суд», а не другая тех-публикация (тип 35 = намерение кредитора, тип 36 = намерение должника на самобанкротство).

Если всё прошло — кейс попадает в новую панель /phase2/intention-seen на дашборде. Туда уже залито 577 случаев за прошедший месяц шадоу-наблюдения.

Алерты. Когда фильтр прошёл — Telegram-алерт. Содержит:
- ИНН + название должника
- Кто опубликовал намерение (кредитор → ИНН + название; «debtor» → должник сам на себя)
- Финансы (выручка, активы из бо.nalog)
- Дата публикации намерения (15-дневное окно от этой даты до автоматического истечения)
- Ссылка на fedresurs.ru

Что ты увидишь

Не сразу. Сейчас алерты идут в отдельный чат, где я их разглядываю первую неделю — смотрю, нет ли явных false-positive паттернов на live-данных (хотя шадоу проверка уже прошла на 577 кейсах). После ревью подключу твой Telegram — увидишь сразу новый поток алертов параллельно с текущими КАД-алертами.

Объём. По историческим данным шадоу-режима — ~50-65 алертов в день. Это сравнимо с текущим объёмом КАД-алертов которые ты получаешь. Если будет слишком шумно — донастроим пороги (выручка, ОКОПФ, дедупликация по INN, исключение банков/ФНС-инициаторов как в обездвиж-pipeline).

Где смотреть прямо сейчас. Если хочешь поковыряться до того, как алерты пойдут к тебе — открой /phase2/intention-seen на дашборде. Там 577 кейсов которые прошли фильтр за месяц шадоу — отсортированы по дате, можно открыть карточку, посмотреть финансы, перейти на fedresurs.

Лежащие в очереди 62 кейса (которые попали в panel но никогда не алертились потому что были обработаны в шадоу-режиме) не будут переподаваться — идемпотентность намерений по messageID не даст повторить. Эти 62 видны на дашборде, но Telegram-алерта по ним не будет. Только новые публикации, начиная с 06:00 UTC 26 мая.

Что НЕ изменилось

  • Phase 1 КАД-pipeline (мониторинг определений, СРО-извлечение, обездвижка, возвраты) — работает как раньше, отдельным потоком.
  • Финансовые пороги discovery (≥300M выручка / ≥1B активы) — без изменений.
  • Telegram алерты по «нашим» делам (target СРО / СРО не указана) — без изменений.
  • Дашборд (КАД-страницы, /costs, /updates) — без изменений.

Технические детали

Issues, PRs, ops-steps, review chain - **Закрытые тикеты:** #135 (revert cron hourly→6h), #136 (review 577 passed-filter cases), #137 (flip `phase2.shadow_mode.intention_stream: false`) - **Code PR:** [#301](https://github.com/evgeni4i4/bankruptcy-monitor/pull/301) — `TELEGRAM_INTENTION_CHAT_ID` per-feature env override + Telegram `getChat` startup probe против persist-first silent-loss (Codex r1 MAJ fix) - **Squash-merged commit:** `da8b0f6` в `main`, deployed на Hetzner **Operational changes на Hetzner (2026-05-26 01:13-01:34 UTC):** 1. `.env` — добавлена `TELEGRAM_INTENTION_CHAT_ID=-4885145745` (chat `kad_intention` — operator review) 2. `config.yaml` — добавлен блок `phase2_config.phase2.shadow_mode.intention_stream: false` 3. `crontab` — `13 * * * *` → `0 */6 * * *` (с шадоу-режима выходим на штатный спецификационный cadence) 4. Backups: `.env.bak.before-intention-chat-*` + `config.yaml.bak.before-intention-flip-*` на Hetzner **Smoke verify:** - Manual sweep 01:31:13 → 01:33:32 UTC: `companies_seen=1001 / new_guids=6 / publications_fetched=0 / passed_filter=0 / alerts_sent=0` (тихое 6h окно, no new publications — нормально) - Direct `sendMessage` к `kad_intention` через bot API: `ok=true, message_id=375`. Chat routing подтверждён. **Idempotency note:** 62 случая в `cases` с `intention_skip_reason IS NULL` AND `alerted_at_intention IS NULL` (passed filter но не alerted в шадоу) **не пере-алертятся**. `namerenie_intentions.messageID` UNIQUE + ON CONFLICT IGNORE — sweep пропустит уже обработанные публикации. Только новые publications, начиная с 06:00 UTC сегодня. **Review chain:** Opus-wrote / Codex r1 REVISE (MAJ: persist-first silent-loss если override chat unreachable — bot вернёт None на API failure, но sweep уже застампил `alerted_at_intention`, навсегда потерянная доставка) → r2 APPROVE (0 CRIT / 0 MAJ) / Gemini skipped / mechanical=green (14/14 tests, ruff clean, mypy 79 baseline unchanged). Probe-guard: `_verify_chat_reachable()` через Telegram `getChat` (read-only, не отправляет сообщения) — runs ПЕРЕД любым DB-стампингом. На failure (HTTP 4xx, network) — `RuntimeError` с actionable error message, cron exits rc != 0, observability surface'ит. Защищает от типу-`TELEGRAM_INTENTION_CHAT_ID=-12345` (chat не существует) ИЛИ бот-не-в-чате сценария.
исправление

Проверка СРО: новый сигнал «требуется ручная проверка»

Pipeline теперь ловит случаи, когда СРО названа в Определении, но автоматическое извлечение её пропустилоВ Telegram такие кейсы приходят с заголовком «⚠️ Требуется ручная проверка СРО» — открой PDF и проверь самЗакрыт ложно-положительный алерт по делу А40-133814 (ООО «ИНТЕР»)

TL;DR

24 мая прилетел алерт по делу А40-133814/2026 ООО «ИНТЕР» с пометкой «СРО: не указана в определении». На самом деле в определении прямо назван Союз «УРСО АУ» (Уральская СОАУ). Один из тех редких случаев, когда сразу три слоя извлечения промахнулись по одной и той же СРО. Сегодня добавили четвёртый слой — sanity check, который ловит именно такие промахи и помечает кейс на ручную проверку вместо тихого мисс-классифицирования.

Что было сломано

На дело А40-133814 пришёл стандартный «лидовый» алерт с фразой «СРО: не указана в определении». При этом в PDF прямо написано:

СОЮЗУ «УРСО АУ» (ИНН 6670019784, ОГРН 1026604954947, адрес: 620014, Свердловская область, г. Екатеринбург) – выполнить требования статьи 45 Федерального закона...

Это Союз «Уральская СОАУ» — не наша target СРО. Если бы pipeline извлёк её корректно, кейс был бы помечен «notified» без Telegram (правило: алертим только absent или target). А так — пришёл ложный лид с неверной формулировкой.

Промах произошёл в двух местах подряд:

  1. Словарь содержит запись для этой СРО только в каноническом длинном виде — «Уральская саморегулируемая организация арбитражных управляющих». В этом PDF длинного названия нет, только аббревиатура «УРСО АУ».
  2. LLM-фолбэк (Claude Haiku 4.5) увидел адресацию суда к СРО под статью 45 и почему-то решил, что СРО «не указана». С confidence 0.95.

Проверили все 94 похожих случая за две недели — А40-133814 единственный реальный промах. Остальные 93 либо реально без указания СРО (court random selection), либо СРО подобрана через другой путь. То есть это не системная проблема, а штучный кейс. Но фикс делает pipeline устойчивее к похожим промахам в будущем.

Что мы сделали

Добавили четвёртый слой защиты — sanity check после LLM. Логика:

  1. Сначала pipeline работает как раньше: словарь → regex → LLM
  2. Если все три сказали «СРО нет» и LLM явно сказал is_absent — запускаем дополнительный regex поиск SRO-форм в тексте (Союз/Ассоциация/СРО/САУ/ААУ + название в кавычках)
  3. Если такая форма нашлась — это сигнал, что extraction может быть неверен
  4. В Telegram приходит обычный лид, но с заголовком «⚠️ Требуется ручная проверка СРО». Открой PDF и проверь сам.

Тело сообщения не меняется — только префикс. Цитату из PDF не показываем, чтобы не направлять внимание на конкретную фразу — лучше открой документ целиком и оцени.

Что ты увидишь

Если придёт похожий случай (СРО названа, но pipeline её не узнал) — алерт прилетит как раньше, но с заголовком про ручную проверку. Открой kad.arbitr.ru, проверь PDF, прими решение:

  • Это target ЦФОП АПК → работаем
  • Это другая известная СРО → пропускаем (как раньше)
  • Это совсем странный случай → дай знать, добавим в словарь

На фоновую работу не повлияет. Все остальные пути обработки (target, multi-SRO «рулетка», обездвижка, возвраты) работают как раньше.

Корректирующее сообщение по А40-133814 отправлено отдельно — увидишь его в чате как раз с warning prefix. СРО в этом деле — Союз «УРСО АУ» (Свердловская). Не наша target, но теперь видно явно.

Что НЕ изменилось

  • Правила извлечения СРО на «обычных» делах (где словарь сработал) — без изменений
  • Гейты на тип определения, прокси-трафик, частоту запросов — без изменений
  • LLM-стоимость — без изменений (новый regex работает мгновенно и бесплатно, LLM-звонок остался тот же)
  • Дашборд — без изменений (audit идёт в JSON-payload нотификаций, отдельной колонки не добавляли)

Что дальше

  • Смотрим, не появится ли новых УРСО-подобных кейсов в течение недели. Если регулярно — добавим эти аббревиатуры в словарь, чтобы они проходили чисто, без warning'а.
  • Если warning будет срабатывать на левых кейсах (где СРО реально не указана, а regex ложно-сработал) — донастроим паттерны.
  • Quality-loop: каждый warning, который ты помечаешь как «реально неверный» или «правильный», помогает калибровать систему. Сейчас feedback ходит через Telegram-чат и текстом — позже можно добавить кнопку «ошибка / ок».

Технические детали

Issue, PR, тесты, разбор полётов - Bug-issue: [#299](https://github.com/evgeni4i4/bankruptcy-monitor/issues/299) — incident по А40-133814 с разбором корневой причины - Dict-only proposal (предыдущая попытка): [#297](https://github.com/evgeni4i4/bankruptcy-monitor/issues/297) — закрыто, заменено более общим фиксом - PR: [#298](https://github.com/evgeni4i4/bankruptcy-monitor/pull/298) — squash-merged в main как `1ac1fa1`, deployed на Hetzner 2026-05-25 Новый модуль `src/parser/sro_conflict_detector.py` — permissive regex с word-boundary lookbehind (чтобы ПРОФСОЮЗ не матчился как СОЮЗ) и paired-quote гарантией (чтобы OCR-артефакты с микс-кавычками не давали false positive). Triggers только на acceptance-class определениях, только когда LLM явно сказал is_absent, только на «inserted=true» ruling rows (идемпотентность на ретраях). Pipeline wiring в `src/pipeline/__init__.py` — `_process_ruling` возвращает 4-tuple с conflict_quote. Routing branch отправляет warning-вариант когда conflict detected, в обход prior-SRO suppression rule (чтобы новые промахи всегда всплывали). Telegram-вариант в `src/notify/telegram.py` — `send_lead(conflict_quote=...)` префиксит сообщение «⚠️ Требуется ручная проверка СРО», тело без изменений. Audit trail в `notifications.payload` JSON: `notification_kind` (lead/conflict_review), `conflict_quote`, `ruling_id`, `ruling_type`, `llm_is_absent`. Без отдельной колонки в `rulings` — для MVP достаточно JSON. **Тесты:** 28 detector unit-тестов (positive: dative СОЮЗУ, genitive АССОЦИАЦИИ, multi-quote, line-break-normalised; negative: ООО/АО debtors, статья 45 одна без СРО, оставление без движения; tightening: ПРОФСОЮЗ, mixed quotes) + 4 Notifier integration. Real-PDF: А40-133814 → `'СОЮЗУ «УРСО АУ»'`, А53-18129 (оставление без движения) → `None`. **Review chain:** Opus-wrote / Codex r1 APPROVE (0 CRIT, 0 MAJ, 3 MIN-fixed на boundary + quote pairing + audit metadata) / Gemini SHIP. Mechanical: ruff clean, mypy 79 vs 80 baseline (одна fewer ошибка, ни одной новой), pytest 92 green на touched suites.
исправление

Полная хронология дел: больше не промахиваемся мимо настоящего Определения

93% дел в БД имели только 1-2 события хронологии, теперь распределение нормальное (3-10 событий)Pipeline больше не скачивает процедурную бумагу вместо настоящего Определения о принятии821 кейс пересканирован, ~1963 события добавлены в БД

TL;DR

После вчерашнего фикса классификатора (ruling-type-classifier) нашёлся второй слой проблемы. Pipeline не просто классифицировал Определение неправильно — он скачивал не то Определение. На 93% дел в БД лежало 1-2 события хронологии вместо реальных 5-10. Сегодня починили + прогнали бэкфилл по 821 кейсу.

Что было сломано

На странице дела kad.arbitr хронология подгружается лениво — сначала видна только шапка, нужно кликнуть «развернуть» чтобы загрузились все события. Pipeline кликал кнопку раньше, чем она появлялась на странице. Клик молча уходил в никуда. В результате pipeline видел только то, что нарисовано до раскрытия — обычно 1-2 строки.

Пример. А44-2533/2026 «ИМПУЛЬЗ» — в реальности есть 3 Определения от 13 мая:
- Истребовать доказательства
- Прочие вопросы
- Принять к производству ← настоящее acceptance

Pipeline видел только первое («Истребовать доказательства»), скачивал его PDF, LLM правильно говорил «другое». Реальный кейс — acceptance — никогда не доходил до Telegram.

Из вчерашних 47 дел в БД с типом другое большинство — именно эта проблема: скачали процедурную бумагу, а не настоящее Определение.

Что мы сделали

Три фикса, выкатанные последовательно за день:

1. Дождаться появления кнопки — pipeline теперь ждёт до 8 секунд пока кнопка развёртывания хронологии появится в DOM. Старый код кликал в пустоту в первый момент после загрузки страницы.

2. Поправить порядок селекторов — pipeline пробует несколько вариантов «кнопки развернуть». Один из вариантов оказался кнопкой отдельной строки хронологии (бесполезно для нашей задачи), и pipeline его выбирал первым. Поменяли приоритет — глобальный селектор теперь идёт первым.

3. Дождаться появления настоящих строк — после клика pipeline ждал «когда что-то появится в контейнере хронологии». Но контейнер появлялся сразу, а реальные строки — через несколько секунд. Pipeline думал «всё, готово» и читал пустоту. Поменяли условие — теперь ждёт именно строки событий, а не их обёртку.

Что ты увидишь

На дашборде — у большинства дел теперь полная хронология. Сравнение распределения:

Сколько событий До фикса После фикса
1-2 события 661 кейс (93%) 63 кейса (9%)
3-5 событий 51 кейс (7%) 386 кейсов (53%)
6-10 событий 0 кейсов 263 кейса (36%)
11-32 события 0 кейсов 41 кейс (~6%)

Откроешь любое дело — увидишь полную картину: когда подали заявление, какие были ходатайства, какие определения суда, в каком порядке.

Пример: А44-2533/2026 «ИМПУЛЬЗ» — было 2 события в БД (одно из них пустое). Теперь 8 событий, включая Определение «Принять к производству» от 13 мая — тот самый сигнал, который pipeline должен был увидеть с самого начала.

В Telegram — будущие сообщения должны быть точнее. Когда придёт настоящее Определение о принятии — pipeline теперь имеет шанс выбрать именно его, а не процедурную бумагу.

Бэкфилл

Прогнали в два захода:

  • Прогон 1 (~2 часа): 675 кейсов с малой хронологией. 254 «выросли», 219 без изменений. Однако последние 192 кейса упали с browser-ошибкой — Chrome исчерпал ресурсы к концу прогона.
  • Прогон 2 (~24 мин): те самые 192 + ещё несколько — 146 кейсов. 77 «выросли», 68 без изменений, 0 ошибок.

Итого: 821 кейс пересканирован, ~1963 события добавлены в БД.

Осталось 62 кейса где хронология короткая (<3 событий). Это либо реально короткие дела (одна заявка → один отказ → конец), либо свежие дела где хронологии пока физически нет. Monitor cron подберёт их сам по мере появления новых тиков.

Что НЕ изменилось

  • Telegram-фильтр на тип определения (из вчерашнего поста) — работает как раньше
  • Пороги выручки/активов на discovery — без изменений
  • СРО-словарь — без изменений
  • LLM-стоимость не выросла — это чисто браузерное исправление

Что дальше

  • Минорный баг: на отдельных кейсах одно событие иногда сохраняется дважды (видно на А65-10445 — Определение 15.04.2026 продублировано). Не влияет на pipeline, но косметически некрасиво. Поправлю в follow-up.
  • Watch-период: в течение нескольких дней посмотрим, перестают ли приходить «не те PDF» в Telegram. Если жалобы типа 18 мая больше не повторяются — фикс зашёл правильно.

Технические детали

Коммиты, тесты, история отладки PR #286 + 3 hotfix-коммита: - `d2dc165` — initial fix через PR #286: добавил wait_for_selector + selector ladder - `657295d` — hotfix 1: `.b-collapse` first в click ladder (был неправильный приоритет) - `bfc58a2` — hotfix 2: tightened extractor row selector (`.b-chrono-item.js-chrono-item`) - `875f3e6` — hotfix 3: wait_for_function ждёт строки, а не контейнер Каждый hotfix вытащен эмпирическим probing'ом на реальных кейсах (А44-2533, А55-16496, А56-54347). Probe → fix → redeploy → probe again. Тестовые харнесы не ловили эти баги потому что патчат JS-output, а живой DOM выглядит иначе. Code review chain: 3 раунда Codex review на initial PR (REVISE → REVISE → APPROVE), плюс отдельные ревью на hotfix'ы. Тесты: 17 в `test_card_fetcher_retry.py` (5 новых под #285), 90 тестов всего по chronology/card — все зелёные. Бэкфилл-скрипт: `scripts/backfill_chronology_events_285.py` + wrapper `cron_backfill_chronology_events_285.sh`. Селектор `events_count < 3`, идемпотентный (повторный запуск пропускает уже заполненные). Distribution до / после (полные числа):
Events  Before  After
1       146     52
2       515     11
3       43      75
4       7       206
5       1       105
6       0       67
7       0       53
8       0       19
9       0       27
10      0       28
11-15   0       56
16-32   0       27
Browser exhaustion на финише первого прогона: после ~3.5 часов работы Chrome держал слишком много zombie-renderer'ов. В rerun процессе с --limit 250 этого не повторилось, но если делать бэкфилл больше 500 кейсов — нужно дробить на чанки.
исправление

Бэкфилл типов определений: 26 кейсов в БД переклассифицированы

Все 3 кейса Дениса от 18 мая (ВИРАЖ, МХ1, УГПХ) теперь в БД с правильными типами26 исторических определений переклассифицировано — отказы / вступления / без движения вышли из категории «принятие»17 свежих кейсов добавлены в watching (вчерашний discovery упал — догнали вручную)

TL;DR

Фазы 5+6 из вчерашнего поста (ruling-type-classifier) выкатили. Бэкфилл 555 исторических определений завершился за 33 минуты, стоил $2.75. Главное: 3 кейса, на которые ты жаловался 18 мая, теперь в БД с правильными типами.

Что починилось ретроактивно

Кейс Было Стало
А56-51039/2026 ВИРАЖ определение_о_принятии отказ в принятии заявления
А56-50051/2026 МХ1 ЛОГИСТИКА определение_о_принятии отказ в принятии заявления
А60-17272/2026 УГПХ определение_о_принятии вступление в дело о банкротстве

Telegram-сообщения по ним мы не отзываем (нельзя). Но в дашборде у этих строк теперь стоит правильный тип. И при любом следующем определении по этим кейсам фильтр уже не сработает.

Что ещё нашлось

Бэкфилл прошёл по 555 определениям, накопленным с начала работы пайплайна. Большинство (514 штук) — это нормализация старого определение_о_принятии в новый enum-форм определение_о_принятии_к_производству. Содержательно ничего не меняется, просто терминология теперь единая.

26 определений получили реальную перекатегоризацию (LLM сказал «не принятие»):

Новый тип Количество Что это значит
Вступление в дело о банкротстве 17 Заявление о вступлении в чужое банкротство, а не самостоятельное дело — не наш кейс
Отказ в принятии заявления 4 Размер долга ниже порога, либо неправильный заявитель
Решение о признании банкротом 2 Это конечное решение, а не определение
Оставление без движения 1 Стадия «обездвижка» — заявитель не подготовил пакет
(повторы при cascade rulings) 2 Те же кейсы попали дважды из-за двух ruling-строк

Среди «вступлений в дело» обнаружилась серия А53-12188 / 12189 / 12194 / 12200 — четыре кейса от РОСТОВ что-то-там подряд, все вступления. Если хочешь, могу выгрузить полный список — пиши в Telegram.

Цифры запуска

  • Phase 1 (бэкфилл хронологии): 59 кейсов, 117 событий, 0 ошибок, 15 минут
  • Phase 5 (бэкфилл типов): 555 определений, 540 UPDATE, 5 ошибок, 2 PDF на диске не нашлись, 33 минуты, $2.75
  • Discovery catchup: 145 кейсов отсканированы за 3 пропущенных дня (17/18/19 мая), 17 свежих кейсов добавлены в watching

Операционная история (для интересующихся)

Что пошло не так и как чинили Деплой не был гладким. Три провала: **1. Discovery вчера упал.** В 6:00 UTC 19 мая casebook-сессия закрылась в момент API-запроса (`TargetClosedError`). Pipeline отрапортовал «Found: 0» и закрылся. Сегодня вручную прогнали 17/18/19 мая — нашлось 91 + 54 + 0 = 145 кейсов, 17 прошли revenue-фильтр. **2. Backfill через `at`-job столкнулся с monitor-cron.** Monitor cron срабатывает каждые 15 минут, держит lockfile. Backfill ждёт освобождения с таймаутом 30 мин — но проходит 2 cron-цикла, lockfile всегда занят. Два раза backfill отвалился по таймауту. **3. Зомби-канарейка с 17 мая держала 14 chrome-процессов через Patchright.** Это съедало память И на свежих сессиях через Evomi-прокси kad.arbitr возвращал блок (DDoS-Guard / pravocaptcha). Когда новая backfill-сессия пыталась подключиться — Page.goto kad.arbitr.ru висел 90 сек и отваливался. Чинили в порядке: убили зомби-канарейку + орфан chrome-процессы (~14 шт, освободили ~400 MB RAM) → отключили monitor cron временно → запустили backfill напрямую через `nohup`. После этого всё прошло за один присест. Durable lesson записан в memory: «browser-based backfills must disable monitor cron first». В следующий раз будет с этого начинать.

Что дальше

  • Monitor cron восстановлен, идёт по 177-case watching pool (160 старых + 17 свежих). Полный проход ≈50 мин.
  • Discovery сегодня в 6:00 UTC возьмёт ещё раз 18-19 мая. Дубликаты идемпотентно пропустятся.
  • Если за следующие сутки прилетит target-SRO (ПАУ ЦФО) на акцепте — Telegram сработает корректно, а отказы и вступления — нет.

Технические детали

Коммиты + production state - `144d876 feat(p284): Phase 5 ruling_type backfill + Phase 6A display wiring` — основной коммит фаз 5+6 - `4aeabd0 docs(changelog): #284 ruling-type classifier post for /updates` — вчерашний анонс - `06f453c feat(out-enrich-07): bump list-org timeout to 90s + changelog` — не связан с #284 Файлы в проде на Hetzner (HEAD `06f453c`): - `scripts/backfill_ruling_types.py` — переклассификация - `scripts/cron_backfill_ruling_types.sh` — wrapper с idempotency-гардом - Phase 6A wiring в `src/web/app.py` + `src/notify/telegram.py` + `src/pipeline/__init__.py` Маркеры завершения: - `/var/lib/bankruptcy-monitor/backfill_chronology_done` — 2026-05-20 03:44:38 UTC - `/var/lib/bankruptcy-monitor/backfill_ruling_types_done` — 2026-05-20 04:18:25 UTC Memory rule добавлено: [`feedback_backfill_disable_monitor_cron`](https://github.com/evgeni4i4/bankruptcy-monitor) — следующий раз перед таким бэкфиллом сначала временно отключать monitor cron, потом стартовать `nohup`.
новая возможность

Tier 6 — Федресурс как резервный источник email для холодной рассылки

в `outreach_contacts` появятся записи с `enrichment_method='federesurs'`срабатывает только когда остальные источники (РКН, rusprofile, 2gis, web_search, сайт компании) вернули 0 emailsпокрытие +2% дополнительных INN на основе probe из 50 случайных делбесплатно — использует тот же Patchright + Evomi стек что и intention_stream cron

Что было раньше

Когда новое дело попадает в watching / notified, наш пайплайн enrichment пытается найти email директора / контактного лица для последующей холодной рассылки. Шесть источников, ранжированные по доверию:

Tier Источник Confidence Покрытие на 50 INN
0 Сайт компании (через rusprofile-сниппет) 90 0%
1 РКН ПДн (реестр операторов) 75 42%
2 2ГИС 70 0% (не активен)
3 Rusprofile 60 4%
4 Web search (Brave API) 30 20%

На 50 случайных INN из активного pool: 22 (44%) не нашли вообще ничего. Эти leads уходили в drop reason outreach.no_email и в рассылке не участвовали.

Что мы сделали

Добавили Tier 6 — Федресурс. Это публичный SPA-backend fedresurs.ru/backend/companies/{guid}, где для каждой компании в реестре банкротных событий есть блок contacts с email/site/phone.

{
  "ogrn": "5147746407815",
  "inn": "7727849985",
  "fullName": "ООО АУДИТ КОНСАЛТ",
  "contacts": {
    "email": "audit@centrcons.ru",
    "site": "auditcon.ru",
    "phone": "8(495)580-64-81"
  }
}

Логика:
1. Для каждого INN из enrichment job сначала прогоняем Tier 0-4 (как раньше).
2. Если хоть один tier нашёл email — Tier 6 не запускается. Post-condition gate. Экономит ~10 секунд per INN.
3. Если все вернули 0 — резолвим INN → GUID через /backend/companies?searchString={INN}, потом fetch /backend/companies/{guid} → если есть contacts.email — добавляем как email candidate с confidence=70 (на уровне 2ГИС).

Все найденные через Tier 6 контакты сохраняются с enrichment_method='federesurs' — видно в дашборде на /outreach/contacts, и можно фильтровать.

Что ты увидишь

На основе probe 50 случайных INN из активного pool:

Tier Сколько INN получили email
Только Tier 6 (без него потеряли бы лид) 1 INN — 0317002279
Tier 6 нашёл, но другие тоже нашли (overlap) 11 INN
Tier 6 ничего не нашёл 38 INN

То есть на каждые 50 новых INN — примерно 1 дополнительный email благодаря Tier 6. Скромный прирост, но бесплатный и одноразовый — раз настроили, работает без расхода квоты, без подписки, без maintenance.

Когда это запустится

Сейчас функция в коде, но выключена по умолчанию. Чтобы активировать в production cron:

# в .env на сервере
OUTREACH_ENABLE_FEDERESURS=1

Это включит Tier 6 для следующего тика email-enrichment cron. До этого момента ничего не меняется — старые источники работают как раньше.

Что дальше

  • Запуск под наблюдением — флипнем OUTREACH_ENABLE_FEDERESURS=1 когда content side для рассылки будет готов (твоё подтверждение V7-шаблона + год начала практики + телефон).
  • Метрика на дашборде — добавим в /costs или /outreach/queue счётчик «emails по source», чтобы было видно сколько Tier 6 даёт incrementally.
  • Не ждём чудес — 2% прирост это не game-changer, но он бесплатный и не блокирующий. Запустим, посмотрим как ведёт себя на полном flow за неделю, если ROI хороший — оставим, если нет — выключим обратно.

Технические детали

Раскрыть
  • Архитектураsrc/integrations/federesurs_email_finder.py::FederesursEmailFinder. Async context manager, оборачивает существующий FedresursPublicProvider (тот же Patchright + Evomi proxy + Qrator cookie acquisition стек что и intention_stream cron — переиспользуем уже отлаженную инфру).
  • Один Patchright launch на batch_run_job открывает сессию один раз, передаёт её во все вызовы find_emails() в этом job'е. Стартап-стоимость ~10s (browser launch + homepage visit + SPA settle) амортизируется по всему батчу.
  • Post-condition gate в find_emails()if fed_finder is not None and not all_emails: .... Tier 6 пускается только когда all_emails пустой после Tier 0-4. На 56% INN (где предыдущие tier'ы хотя бы что-то нашли) Tier 6 пропускается → экономим ~10s wall-clock per INN.
  • Per-INN cost — 2 API call'а (search by INN → fetch company card) × 5s rate gate = ~10s. Безсерверный fetch_json через page.evaluate("fetch(...)") внутри JS-контекста SPA (это единственный способ обойти Qrator + Sec-Fetch-Mode mismatch). Все вызовы внутри одной сессии серилизуются через asyncio.Lock.
  • Профиль изоляции — Tier 6 использует /tmp/patchright_outreach_profile, не /tmp/patchright_fedresurs_profile (тот занят intention_stream cron'ом каждый час на :13). Stale Singleton* локи (от crash'нутого предыдущего runs) снимаются на старте сессии — defense-in-depth по feedback_patchright_profile_copy_strip_singleton.md.
  • Session close timeout 30s — Patchright context.close() иногда висит на завершении persistent context (наблюдали 14-минутный hang в smoke test). Обернули в asyncio.wait_for(timeout=30) чтобы cron tick всегда завершался; если Chrome subprocess откажется умирать, следующий tick снимет Singleton* локи и запустится заново.
  • Schema migrationenrichment_method CHECK constraint расширен с 'federesurs'. Sentinel в _migrate_outreach_contacts_enrichment_method_check (тот что short-circuit'ит rebuild) обновлён с 'rkn_pdn' на 'federesurs' — иначе на серверах где есть 'rkn_pdn' (после 2026-05-14 hotfix) но нет 'federesurs' rebuild не сработал бы.
  • Feature flagOUTREACH_ENABLE_FEDERESURS=1 в .env. Прошивается через cron_outreach_email_enrichment.sh_enqueue_all(enable_federesurs_tier=...)enqueue_job(...)_run_job(...).
  • Тесты — 3 новых в tests/test_email_enrichment.py:
  • test_find_emails_tier6_fires_when_other_tiers_return_zero — gate happy path
  • test_find_emails_tier6_skipped_when_other_tier_hits — gate skip path
  • test_find_emails_tier6_failure_does_not_crash — exception tolerance
  • Probe artifactsscripts/_oneshot/probe_federesurs_email_v3.py (50-INN coverage), scripts/_oneshot/probe_all_tiers_exhaustive.py (per-INN per-tier breakdown), scripts/_oneshot/smoke_test_tier6.py (end-to-end через enqueue_job).
  • Commitab0d551 «feat(out-enrich-06): wire Federesurs SPA as Tier 6 post-condition fallback», 7 файлов, +451/-5 LOC, 119/119 focused tests passing.
  • Backstory — пробовали ту же роль на DataNewton paid Профи: probe на 12 INN с подтверждёнными Federesurs-emails вернул 0/12 contacts в demo (200 запросов). Запросили support, ждём ответа по платному tier. Если в paid тоже null — Federesurs остаётся единственным free источником contacts на наш use case.
новая возможность

Tier 7 — list-org.com как ещё один резервный источник email

в `outreach_contacts` появятся записи с `enrichment_method='listorg'`срабатывает только когда РКН, rusprofile, 2gis, web_search, сайт компании И Федресурс (Tier 6) вернули 0 emailsпокрытие 58% INN на основе probe v3 из 12-INN контрольного набора (~33% дают email, которого нет в Федресурсе)бесплатно — использует тот же Patchright + Evomi стек что Tier 6

Что было раньше

В предыдущем апдейте мы включили Tier 6 — Федресурс. На probe из 50 случайных INN он давал +1 incremental email из 22 «пустых» (т.е. где первые 5 источников ничего не нашли). 21 INN из тех 22 всё равно оставался без email.

list-org.com — это публичный community-агрегатор данных по российским юрлицам. На многих карточках компаний есть Email: поле, которое не публикуется ни на Федресурсе, ни в РКН, ни в rusprofile. Probe v3 на 12 контрольных INN показал:

Метрика Значение
INN с email на list-org 7 / 12 (58%)
Email отличается от Федресурса (incremental) 4 / 12 (33%)
Email совпадает с Федресурсом (кросс-сверка) 3 / 12 (25%)
INN, где list-org нашёл email, а Федресурс нет 4 / 12

Т.е. list-org даёт ~33% полностью новых email, плюс ~25% дополнительного подтверждения, что email в Федресурсе настоящий.

Что мы сделали

Добавили Tier 7 — list-org. Это полноценный SPA-скрейпер: открывает Chromium через Evomi residential proxy, ходит на list-org.com, ищет компанию по ИНН, парсит карточку.

https://www.list-org.com/search?type=inn&val=7727849985
  → редирект на /company/9302185
  → карточка с блоком «Email:», «Телефон:», «Сайт:»

Логика:
1. Прогоняем Tier 0-6 (как раньше + Федресурс).
2. Если хоть один tier нашёл email — Tier 7 не запускается. Тот же post-condition gate что и Tier 6. Экономит ~70 секунд per INN.
3. Если все вернули 0 — открываем list-org, фетчим карточку, парсим email через regex, фильтруем по deny-list (без noreply/admin/служебных доменов).
4. Сохраняем найденные email с enrichment_method='listorg', confidence=60 (на уровне rusprofile).

Антибот list-org

list-org защищается кастомным капча-механизмом (Sekuritxr + ACINT + DigitalCaramel поверх стандартного fingerprint check). При rate-limit редиректит на https://www.list-org.com/bot?from=<original_url> с чекбоксом «Проверка, что Вы не робот».

Мы капчу не решаем — мы её детектим и обходим. Логика:
- Каждая попытка получает свежую Evomi-сессию через суффикс _session-<random> в пароле прокси → новый exit IP.
- Если после goto URL содержит /bot — попытка считается провалившейся, ждём 15s, retry с новой сессией.
- До 3 попыток. Probe v3 показал: после 1-3 попыток с ротацией прокси всегда либо успех, либо данные не индексированы вообще (нет смысла повторять).

Никакого CAPTCHA-solving сервиса не нужно — block intermittent, ротация IP разруливает.

Smoke на проде сегодня

Прогнал на 3 ИНН:

ИНН Что показал ремейн (Tier 0-6) Tier 7
5404224037 СИБСТРОЙСЕРВИС РКН + Brave → 3 emails gate закрыт, не пускался
7727849985 АУДИТ КОНСАЛТ РКН + Brave → 3 emails gate закрыт, не пускался
7810617468 МАСЛОВДОМ 0 emails (все 5 источников молчат) gate открыт → list-org → карточка нашлась → но в ней только 5 телефонов, без email

Третий случай — это нормальное поведение «42% компаний на list-org без email» (помним, probe v3 показал 58% с email). Логика работает корректно: lookup_email вернул attempts=1, emails=(), phones=(5) за 11.5 секунд, ничего в outreach_contacts не записалось потому что email там реально нет.

Что ты увидишь

Tier 7 включен в production (OUTREACH_ENABLE_LISTORG=1 в .env, миграция применена). На каждом тике email-enrichment cron он будет дополнительно срабатывать на тех INN, где предыдущие 6 источников вернули 0 emails. Ожидаемый прирост: +33% от тех INN, что доходят до Tier 7.

В outreach_contacts начнут появляться строки с enrichment_method='listorg' (видно в дашборде /outreach/contacts, есть фильтр). Confidence=60 — на уровне rusprofile, ниже чем 2ГИС (70) и Федресурс (70), но выше чем web_search (30).

Тонкие моменты

  • Список-орг медленный через Evomi. Сегодняшний смок-тест показал что главная страница загружается ~63 секунды через текущий Evomi exit pool. Поэтому таймаут на goto поднял с 45s до 90s. Один INN в худшем сценарии (3 попытки × ~90s × 3 страницы + 2 backoff'a × 15s) — это до 14 минут wall-clock. Реальный кейс с одной успешной попыткой — ~70-90 секунд.
  • Tier 7 не блокирует cron. Если list-org вернёт homepage_err (как было в первом раунде смока до фикса таймаута) — error логируется, find_emails продолжает работу, в outreach_contacts тот INN остаётся с enrichment_method=NULL.
  • Tier 6 + Tier 7 совместимы. Tier 7 запускается только если И прочие источники, И Tier 6 ничего не вернули. Это значит, что когда у компании есть запись в Федресурсе с email — Tier 7 не пытается.

Что дальше

  • Наблюдать неделю — посмотреть на /outreach/contacts через 7-10 дней какой реальный прирост дают Tier 6 + Tier 7 combined на полном flow.
  • Если list-org-block станет частым (>50% INN-attempts hit /bot?) — можно добавить дополнительные провайдеры прокси или увеличить MAX_ATTEMPTS до 5. Пока что probe v3 показал stable работу с 3 попытками.
  • Метрика на /costs — добавить разбивку «emails по source» так же как для Tier 6 (вместе с ним), чтобы видеть incremental yield обоих новых источников.

Технические детали

Раскрыть
  • Архитектураsrc/integrations/listorg_email_finder.py::ListOrgEmailFinder. Async context manager. В отличие от FederesursEmailFinder (где retry / session rotation делал внешний слой через переиспользование intention_stream инфры), здесь retry-цикл встроен в сам lookup_email — потому что list-org-block stochastic (первая попытка часто фейлится, второй-третий обычно проходит) и протокол recovery должен жить рядом с детектом.
  • Per-attempt fresh session — каждая попытка пересобирает proxy URL c новым _session-<random> суффиксом в пароле → новый Evomi exit IP. Без этого retry бьёт в тот же заблокированный exit и никогда не пройдёт.
  • /bot?from=... детект — после page.goto проверяем page.url на /bot и page.content() на «Проверка, что Вы не робот». Это явный signal блока. При детекте: incrementим retry counter, close context, sleep 15s, retry с новой сессией.
  • wait_until="load", не networkidle — Evomi residential через chatty SPA-аналитику list-org никогда не отдыхает до networkidle (см. feedback_patchright_wait_until_load.md).
  • channel="chrome", не bundled Chromium — pre-installed Google Chrome на Hetzner. Bundled Chromium fingerprint детектится list-org.
  • Без playwright-stealth — double-patching с Patchright ломает context-level proxy injection (см. feedback_no_stealth_on_patchright.md).
  • _strip_singletons на каждой attempt — снимаем SingletonLock/Cookie/Socket с профиля перед launch_persistent_context, чтобы предотвратить 180s hang на stale lock от crash'нувшегося Chrome (см. feedback_patchright_profile_copy_strip_singleton.md).
  • pages_fetched корректно учитывает попыткиpages_fetched += listorg_result.attempts * 2 (2 страницы — search + detail — per attempt). Если случилось 3 retry — 6 страниц учитываются в дашборде расхода прокси.
  • Schema migrationenrichment_method CHECK constraint расширен с 'listorg'. Sentinel в _migrate_outreach_contacts_enrichment_method_check обновлён с 'federesurs' на 'listorg' — иначе на серверах с Tier 6 (но без Tier 7) rebuild не сработал бы.
  • Feature flagOUTREACH_ENABLE_LISTORG=1 в .env. Прошивается через тот же cron_outreach_email_enrichment.sh_enqueue_all(enable_listorg_tier=...)enqueue_job(...)_run_job(...). Tier 6 и Tier 7 независимы — можно включить любой по отдельности.
  • _close_listorg_finder 60s timeout (дольше fed'овского 30s) — потому что in-flight попытка может держать playwright context до 90 секунд во время retry backoff. Если умирать должны — пусть умирают аккуратно после max-attempt; cron tick всё равно завершается, следующий tick снимет stale Singleton* locks.
  • Тесты — 4 новых в tests/test_email_enrichment.py:
  • test_find_emails_tier7_fires_when_other_tiers_return_zero — gate happy path
  • test_find_emails_tier7_skipped_when_other_tier_hits — gate skip path
  • test_find_emails_tier7_failure_does_not_crash — exception tolerance
  • test_find_emails_tier7_retry_attempts_counted_in_pages_fetched — retry telemetry
  • Probe artifactsscripts/_oneshot/probe_list_org_v3.py (12-INN coverage), scripts/_oneshot/smoke_tier7.py (3-INN end-to-end через enqueue_job), scripts/_oneshot/smoke_tier7_round2.py (single-INN re-smoke с увеличенным таймаутом).
  • Smoke discoveries — round 1 смок-теста (45s GOTO_TIMEOUT_MS) показал что все 3 попытки фейлятся: Page.goto: Timeout 45000ms exceeded. Diagnostic probe измерил реальный load homepage = 63s. Поднял GOTO_TIMEOUT_MS 45 → 90s и RETRY_BACKOFF_SECONDS 30 → 15s (всё равно block stochastic, нет смысла долго ждать). Round 2 после фикса — 1 попытка, 11.5s, success.
  • Commit5850c5a «feat(out-enrich-07): wire list-org SPA as Tier 7 post-Federesurs fallback», 7 файлов, +783/-3 LOC, 123/123 focused tests passing. Hot-patch таймаута + лога — TODO в следующем коммите.
новая возможность

Классификатор определений: отказы и вступления больше не падают в Telegram

4 кейса 18 мая (ВИРАЖ, МХ1, УГПХ, АРС-СТРОЙ) — pipeline теперь правильно различает «принятие», «отказ», «вступление в дело»Telegram больше не шлёт «определения» которые на самом деле отказы или вступления в чужое делоВ сообщении TG появилась строка «Тип акта» — видно сразу что за определение551 историческое определение будет переклассифицировано 20 мая

Что прислал Денис

18 мая в 13:00 MSK ты прислал серию определений с одинаковым возмущением:

  • А56-51039/2026 ВИРАЖ — пришло в Telegram как «определение о принятии», но в PDF — определение об отказе в принятии заявления (размер долга ниже порога)
  • А56-50051/2026 МХ1 ЛОГИСТИКА — то же самое: на самом деле отказ
  • А60-17272/2026 УГПХ — это вступление в дело о банкротстве другого должника, а не самостоятельное банкротное дело. Не наш кейс — судьба компании зависит от чужого процесса.
  • А40-132903/2026 АРС-СТРОЙ — это действительно принятие, и СРО там «ПАУ ЦФО». Правильный сигнал.

Из четырёх три пришли с неправильным типом. Реальный конверсионный сигнал — один.

Что было сломано

Pipeline смотрел на текст хронологии дела на kad.arbitr и пытался по подстроке «принят» отличить определение о принятии от прочих. Подвох: «определение об отказе в принятии» тоже содержит слово «принят». Тот же класс ошибки на «определение о вступлении в дело» — формально это «принятие заявления» (заявления о вступлении), хотя экономически — совсем другой кейс.

Простой substring-match ловил всё что пахло «принятием» и пихал в Telegram.

Что мы сделали

Разложили на 6 фаз. Все шипились последовательно 19 мая.

Phase 1 — Структурный экстрактор хронологии

Перешли от тыкания в HTML строки на структурированный обход DOM. Каждое событие в хронологии теперь сохраняется в отдельную таблицу chronology_events с типом события, датой, ссылкой на документ, текстом. Это даёт полную аудиторию: что суд делал по делу, когда, в каком порядке.

Phase 2 — Предварительный классификатор по структуре события

Вместо substring-match по одной строке — связка из правил:
- если в названии события есть «об отказе» — это отказ, какие бы там ни были последующие слова
- если есть «вступление в дело» — это вступление, не самостоятельное банкротство
- если ни то ни другое + есть «принят*» → возможное принятие, но с пометкой AMBIGUOUS

AMBIGUOUS уходит на LLM — финальный судья.

Phase 3 — LLM-классификатор: тип определения + СРО в одном вызове

Раньше LLM звался только для поиска СРО. Расширили промпт: один и тот же вызов теперь возвращает и ruling_type (тип определения из 8 вариантов: принятие / отказ / вступление / без_движения / возврат / признание_банкротом / постановление_о_принятии / другое), и sro_name.

Цена не изменилась — это тот же вызов с расширенным промптом, не дополнительный. Замер на 13 PDF: 100% правильных классификаций ruling_type.

Phase 4 — Telegram-фильтр на тип определения

Теперь Telegram срабатывает только на определения с типом из белого списка:
- определение о принятии к производству
- постановление о принятии (апелляционная инстанция)

Всё остальное (отказы / возвраты / вступления / без_движения / признания банкротом) — молча сохраняется в БД, виден в дашборде с правильным типом, но не шлёт Telegram.

Phase 5 — Бэкфилл исторических типов

Шипилась только сегодня. 551 определение которое уже было в БД переклассифицируется через тот же LLM-вызов: для каждого качаем закешированный на диске PDF, LLM решает, апдейтит rulings.ruling_type если новое значение отличается от старого.

Запускается завтра 20 мая в 07:00 UTC (через сутки после хронологического бэкфилла Phase 1, чтобы не пересекаться по ресурсам). Стоимость ~$2, время ~20 минут. Старые Telegram-сообщения не отзываются (мы их и не можем) — но в дашборде кейсы получат правильный тип.

Phase 6 — Единая терминология

Снэйк-кейс из БД (определение_о_принятии) больше не уезжает в интерфейсы как есть. Все три точки рендеринга — дашборд, Telegram, /issues — проходят через один общий словарь:

  • определение_о_принятии_к_производству → «Принятие заявления к производству»
  • определение_об_отказе_в_принятии → «Отказ в принятии заявления»
  • определение_о_вступлении_в_дело → «Вступление в дело о банкротстве»
  • определение_об_оставлении_без_движения → «Оставлено без движения»

В Telegram-сообщениях появилась новая строка «Тип акта:» прямо над СРО — видно сразу что за определение пришло. Раньше приходилось догадываться по тексту PDF.

Что ты увидишь

  • ВИРАЖ (А56-51039): остался в БД как «отказ в принятии», Telegram больше не пришлёт повторно
  • МХ1 ЛОГИСТИКА (А56-50051): то же — отказ в принятии
  • УГПХ (А60-17272): вступление в дело, Telegram не сработает
  • АРС-СТРОЙ (А40-132903): продолжит работать корректно — принятие, ПАУ ЦФО
  • Все будущие определения: фильтр работает на потоке — отказы и вступления молча в БД, принятия в Telegram

20 мая после 07:30 UTC посмотри в дашборд старые кейсы — у них появятся правильные типы. Если найдёшь странности (например, было «принятие», стало «отказ»), пиши — это значит наш изначальный классификатор по хронологии ошибся, а LLM поправил.

Что НЕ изменилось

  • Поиск СРО (словарный + LLM) — без изменений
  • Расчёт «жирности» должника, фильтры выручки/активов — без изменений
  • Multi-SRO «рулетка» (определение с несколькими СРО) — без изменений
  • Стоимость LLM-вызовов не выросла: ruling_type идёт в том же вызове что и SRO

Что дальше

  • 20 мая 07:00 UTC — бэкфилл 551 исторического определения (Phase 5). Логи в /data/logs/backfill_ruling_types_*.log на проде. Идемпотентность: повторный запуск не сделает второй вызов LLM на тот же PDF.
  • Возможный side-effect: на «отказы» которые сейчас лежат в notified статусе, переклассификация изменит ruling_type, но статус кейса не изменится — он остаётся в дашборде как ты привык. Меняется только колонка «Тип определения».

Технические детали

Коммиты + затронутые файлы - `0652536 feat(chronology): structured DOM extraction + chronology_events table (#284 Phase 1)` - `cfa6037 feat(display): unified ruling_type / event_type display strings + chronology backfill (#284)` - `8900b1b feat(classifier): Phase 2 pre-classifier on structured events (#284)` - `df4cd04 feat(llm): Phase 3 combined ruling_type + SRO LLM extractor (#284)` - `57ea11d feat(notify): Phase 4 ruling_type filter for Telegram (#284)` - `8a45b66 feat(p284): Phase 5 ruling_type backfill + Phase 6A display wiring` Затронутые файлы: - `src/scraper/chronology_extractor.py` — структурный DOM-парсер - `src/scraper/card.py` — wire chronology events в основной pipeline - `src/parser/sro_extractor_llm.py` — расширенный промпт + ruling_type enum - `src/parser/display_strings.py` — общий словарь имён - `src/pipeline/__init__.py` — notify filter + forward ruling_type в send_lead - `src/notify/telegram.py` — строка «Тип акта» - `src/web/app.py` — `ruling_type_display` в JSON - `src/db/schema.py`, `src/db/repo.py` — `chronology_events` table + `ruling_type_llm` column - `scripts/backfill_chronology_events.py` — историческая загрузка событий (фаза 1 backfill, фаерится 19 мая 05:15 UTC) - `scripts/backfill_ruling_types.py` — историческая переклассификация типов (фаза 5 backfill, фаерится 20 мая 07:00 UTC) Все 6 фаз: ~150 новых тестов, mypy + ruff чисто. Идемпотентность бэкфиллов: - `/var/lib/bankruptcy-monitor/backfill_chronology_done` — маркер завершения Phase 1 - `/var/lib/bankruptcy-monitor/backfill_ruling_types_done` — маркер завершения Phase 5 (Phase 5 cron не запустится пока Phase 1 не закончит) Тип определения в БД: - Жёсткий enum из 8 значений в `_RULING_TYPE_ENUM` (`src/parser/sro_extractor_llm.py`) - LLM возвращает значение из enum + `ruling_type_quote` (цитата из заголовка для аудита) - Если LLM возвращает значение off-enum — игнорируется, остаётся то что выдал chronology classifier
исправление

СРО-классификатор: убрали flapping, перешли на Haiku, добавили consensus

3 кейса 18 мая (АРС-СТРОЙ, ОНКОТАРГЕТ, МХ1 ЛОГИСТИКА) — pipeline видел СРО, дашборд показывал пустоLLM теперь работает с текстом PDF а не с image-каналом — 0% flap по всем моделямgpt-4o → claude-haiku-4.5 — та же точность, 1.8× дешевлеconsensus-логика теперь в живом pipeline — баги вроде «3 positive + 5 absent → пусто» больше не возможны

Что прислал Денис

18 мая в 12:50 MSK ты прислал серию определений из дашборда с пометками — где СРО есть, где нет, где наша мониторилка ошиблась.

В 3 кейсах подряд ты написал «здесь СРО указано», а в дашборде стояло «СРО не указана в определении»:

  • А40-132903/2026 АРС-СТРОЙ (выручка 910M ₽, банк «РУСНАРБАНК» подал)
  • А40-129536/2026 ОНКОТАРГЕТ (выручка 2.7B ₽)
  • А56-50051/2026 МХ1 ЛОГИСТИКА (выручка 313M ₽)

Я полез в БД и нашёл кое-что неприятное: для двух из трёх случаев LLM правильно нашёл СРО — но дашборд об этом не знал.

Что было сломано

Проблема 1 — gpt-4o дрейфовал на одном и том же PDF

На А40-132903 АРС-СТРОЙ:
- LLM-вызов в 09:02 → «is_absent» (СРО нет)
- LLM-вызов в 12:43 → «ПАУ ЦФО» — нашёл!
- LLM-вызов в 16:06 → «is_absent»
- LLM-вызов в 20:40 → «ПАУ ЦФО»
- ... и так каждые 3-4 часа, то находит, то нет

Один и тот же PDF, одна и та же модель, разные ответы. Это называется «flapping» — модель сама с собой не согласна между прогонами.

Проблема 2 — pipeline помнил только первый ответ

Когда мониторинг находил новое определение, он:
1. Качал PDF
2. Спрашивал LLM
3. Записывал ответ в rulings.sro_name

Если первый прогон сказал «is_absent» — rulings.sro_name сохранялся пустым. Все следующие прогоны (которые иногда давали правильное «ПАУ ЦФО»!) писались в audit-таблицу llm_extractions, но сам ruling больше не обновлялся.

То есть pipeline 3 раза узнал правильный ответ, но не сделал с этим ничего. В дашборде так и оставалось пусто.

Проблема 3 — pdf_engine плагин вместо обычного текста

Раньше LLM-вызов отправлял PDF в base64 вместе с инструкцией для OpenRouter «прогони через mistral-ocr плагин и сам найди в нём текст». Это работало, но добавляло слой нестабильности — рендеринг страницы в изображения и обратная OCR-распознавалка дают на одинаковом PDF немного разный текст между прогонами. Этого было достаточно чтобы модель «передумала».

Что мы сделали

Решение разложилось на три фазы. Все три задеплоили 18 мая вечером.

Phase 1 — Перешли с PDF-base64 на обычный текст

Вместо отправки PDF в LLM, мы локально вытаскиваем текст через pdfplumber (та же библиотека что уже используется для словарного поиска СРО — dict + regex путь), и отправляем в LLM только текст. Никаких image-каналов, никаких OCR-плагинов.

Это сразу убрало flapping у текущей модели gpt-4o.

Phase 2 — Сменили модель

Прогнали бенчмарк: 5 моделей × 10 PDF × 5 прогонов = 250 калов:

Модель Точность (F1) Стоимость / прогон Скорость
Haiku 4.5 96% $0.0038 1.8 сек
Sonnet 4.6 96% $0.0114 (3× дороже) 3.5 сек
Gemini Flash 83% $0.0014 1.2 сек
gpt-4o (старая дефолтная) 96% $0.0069 1.6 сек
gpt-5.4 92% $0.0092 2.3 сек (хвосты до 14 сек)

Haiku 4.5 даёт ту же точность что gpt-4o, но в 1.8 раза дешевле, при той же скорости. Sonnet был бы перебор — те же 96% за тройную цену. Gemini Flash самый дешёвый, но добавляет ложные срабатывания (71% precision против 92% у Haiku). gpt-5.4 имеет длинные хвосты latency — 14 сек worst case ломает 15-минутный cron-тик.

Выбрали Haiku 4.5 как баланс цены и точности.

При текущих ~6000 LLM-вызовов в месяц это даёт экономию с $41 до $23 в месяц. Не катастрофа сама по себе, но стабильнее и быстрее накапливается с ростом объёма.

Phase 3 — Добавили consensus-логику в живой pipeline

Раньше pipeline доверял первому ответу LLM. Если повезло — записал правильно. Если нет (как с АРС-СТРОЙ) — записал пусто.

Теперь после каждого LLM-вызова pipeline:
1. Делает свежий вызов модели → получает ответ
2. Запрашивает все прошлые ответы для этого кейса из audit-таблицы
3. Применяет правило большинства:
- Если ≥2 прогонов согласны что СРО — это «ПАУ ЦФО» → принимаем «ПАУ ЦФО», даже если последний прогон сказал «is_absent»
- Если ≥2 прогонов согласны что СРО нет → принимаем «нет»
- Если прогоны разделились между разными СРО → флаг «нужен ручной просмотр», не записываем
4. Если consensus положительный — атомарным UPDATE правит rulings.sro_name, даже если он был ранее пустым

Это значит:
- Старые кейсы где первый прогон ошибся будут самофиксироваться на следующем прохождении мониторинга. Например, твой АРС-СТРОЙ автоматически получил «ПАУ ЦФО» в дашборде через несколько минут после того как я задеплоил Phase 3.
- Будущие баги моделей этого класса (даже если Haiku однажды flap-нёт) будут компенсированы — система самостоятельно соберёт верное мнение из нескольких прогонов.

Что ты увидишь

  • АРС-СТРОЙ (А40-132903): уже починен, в дашборде стоит «ПАУ ЦФО»
  • ОНКОТАРГЕТ (А40-129536): исправится на следующем прохождении (≤15 мин после следующего monitor-цикла), станет «МСО ПАУ»
  • МХ1 ЛОГИСТИКА (А56-50051): это отказ в принятии заявления — там СРО действительно нет, потому что суд ничего не назначает. Это другой баг (тип определения отображается неправильно), он трекается отдельно — issue #284.

Что НЕ изменилось

  • Словарный + regex-путь поиска СРО остался первым. Только если он не нашёл — зовём LLM.
  • Multi-SRO (рулетка) обрабатывается отдельно через словарный путь — мы это уже шипили 15 мая, см. предыдущий пост.
  • Стоимость АU-сопровождения / триггеры Telegram-уведомлений не менялись.

Что осталось

  • Issue #284 — фильтр для «отказов в принятии» и «вступлений в чужое дело». Чтобы такие определения не падали в notification как «определение о принятии». Это твой комментарий 18 мая 13:23 («давай это зафиксим, в целом не критично, но здорово было бы не получать такое»).
  • Единая терминология — там же про «тяжело читается и спотыкаюсь». Будет следующим раундом.

Технические детали

Коммиты + затронутые файлы - `8f7b693 feat(sro-llm): markdown input + claude-haiku-4.5 default (#283)` — Phase 1+2 - `603b8b2 refactor(sro-llm): drop pdf_engine parameter (#283 Phase 3a)` — cleanup устаревшего параметра - `960072a feat(sro-llm): wire decide_sro consensus into live pipeline (#283 Phase 3b)` — Phase 3 Затронутые файлы: - `src/parser/sro_extractor_llm.py` — основной экстрактор - `src/parser/sro_consensus_apply.py` — новый модуль consensus - `src/pipeline/__init__.py` — wire consensus в `_try_llm_fallback` - `src/db/schema.py`, `src/db/repo.py` — audit comments - `config.yaml`, `src/config.py` — модель по умолчанию - Тесты: 53/53 проходят, 7 новых регрессионных тестов на сценарий АРС-СТРОЙ Benchmark-инфраструктура: `scripts/benchmarks/sro_llm/` (PDF → markdown converter, OpenRouter harness, scoring). Можно перезапустить когда выйдут новые модели — replace список `MODELS` в `bench.py` и запустить, получишь свежее сравнение.
исправление

Мониторинг молчал 2 дня — починили двухслойную защиту от подвисаний

инцидент 14–17 мая — пропущенные определения за ~64 часа подтянулись из backlogbash-обвязка cron теперь форс-килит зависший процесс через 2 часаPython-клиент браузера теперь не может зависнуть навсегда на закрытии

Что произошло

В четверг 14 мая в 18:00 MSK мониторинг подавился на kad.arbitr и замолчал на 2 дня 20 часов — до утра воскресенья 17 мая, когда ты прислал «pipeline has error, watch logs».

Снаружи это выглядело так:
- Telegram-уведомления о новых определениях перестали приходить
- В Telegram прилетел single alert «pipeline error»
- Дашборд работал, дискавери новых дел в watching тоже работал — то есть «половина системы» жила
- А именно проверка определений на kad.arbitr — стояла

Подтянулось всё, как только мы пнули руками: 645 дел в очереди, первое уведомление за 64 часа простоя — А73-6533/2026 «АГВУС» (ЦФОП в определении от 15 мая) — ушло через 1 минуту после починки. Остальной backlog ушёл в течение нескольких часов.

Что сломалось технически

Цепочка такая:

  1. 14 мая 18:00 MSK. kad.arbitr перестал отдавать страницы карточек — каждый запрос упирался в 90-секундный таймаут (антибот pravo.tech включил защиту на наш прокси-IP). Это нормальное состояние — раз в недели-две прокси сжигается, и мы переключаемся на новый.

  2. Python поймал фатальную ошибку и пошёл закрывать браузер. Это штатная процедура — на любой fatal сценарий мы корректно гасим Chromium, освобождаем прокси, пишем статистику в базу.

  3. Закрытие зависло. Браузер был в «отравленном» состоянии после антибот-блока. Команда «закрой страницу» уходила в Chromium через цепочку pipe → Node.js → CDP → Chrome, и где-то по дороге пайп оборвался без ответа. Python-вызов browser.close() тихо завис в ожидании ответа, который не придёт никогда.

  4. Bash-обёртка ждала Python. Python жив → не завершился → bash жив → лок-файл /tmp/bankruptcy-monitor.lock остался лежать.

  5. Каждый следующий cron-тик (каждые 15 минут) видел лок-файл, проверял жив ли держатель — да, жив! — и тихо выходил. Никаких ошибок, никаких алертов. Просто Monitor already running (PID 2936547), skipping. 192 раза подряд.

Итог: 2 дня 20 часов мониторинг был «как бы запущен», но реально ничего не делал. RAM съело 4.6 GB на зомби-процессах Chromium. Никаких признаков снаружи кроме отсутствия Telegram.

Что мы сделали

Защиту построили в два независимых слоя — если один пропустит, второй поймает.

Слой 1 — bash-обвязка (PR #276)

В scripts/cron_monitor.sh добавилась проверка возраста лок-файла. Логика:

  • Лок есть и держатель жив → проверь сколько лок-файлу часов
  • Меньше 2 часов — нормальная ситуация, штатная работа, выходим тихо
  • Больше 2 часов — это аномалия, никакой штатный запуск столько не длится. Принудительно убиваем держателя со всем поддеревом процессов, чистим осиротевшие Xvfb-дисплеи старше 2 часов, удаляем лок, продолжаем нормально

Это первая страховка. Даже если Python завис «насмерть», cron на следующем тике после 2-часовой отметки сам себя починит — без вмешательства руками.

Слой 2 — Python-клиент браузера (PR #279)

В src/scraper/client.py каждый вызов «закрой» теперь обёрнут жёстким таймаутом:

  • page.close() — 10 секунд
  • context.close() — 10 секунд
  • browser.close() — 15 секунд
  • playwright.stop() — 15 секунд

Если Chromium не отвечает за это время — Python не ждёт бесконечно, а пишет warning в лог и идёт дальше. Процесс завершается штатно → лок-файл снимается → cron на следующем тике запускается чисто.

В худшем сценарии (Chromium совсем не отвечает): Python теряет до 15 секунд на каждый из 4 шагов закрытия = максимум минута. Это не «зависание на 2 дня».

Что покрыто двумя слоями

Сценарий Слой 1 (bash) Слой 2 (Python)
Штатный fatal с быстрым закрытием не активируется не активируется
Закрытие зависло на pravo.tech-блоке сработает через 2 ч сработает через ≤1 мин
Python завис вне процедуры закрытия (бесконечный цикл, deadlock) сработает через 2 ч не сработает
Bash-обвязка сломалась (порча скрипта, сбой xvfb-run) не сработает поможет если успеет

Любая комбинация — максимум 2 часа простоя вместо «пока ты не напишешь "watch logs"».

Что ты увидишь

В нормальной работе — ничего. Это защита, она не должна срабатывать на штатных запусках.

Если в логах когда-то появятся строки вида:

WARN: monitor PID 12345 alive 7250s > 7200s — force-killing hung tree

или

browser.close() timed out / errored: ...

— значит защита сработала. Один из двух слоёв поймал зависание, система продолжает работать. Можно ничего не делать — для информации.

Конкретно сейчас:
- Backlog за 14–17 мая полностью обработан, все пропущенные определения подтянуты
- Telegram-уведомления приходят как обычно
- 16 открытых задач в проектной доске (после очистки от 41) — продолжаем по плану

Что дальше

  • Наблюдаем 7 дней. Если за неделю не было ни одного срабатывания force-kill — значит штатно (хорошо: бан-окно у нас редкое). Если было 1–2 — отлично, защита работает по делу.
  • Если зависания участятся — поднимаем приоритет на более глубокий fix в самой обвязке Chromium (отдельный апстрим-issue в patchright). Сейчас это пока единичный случай за месяц.
  • Хроническая утечка Xvfb-дисплеев (8 штук висели с 4–12 мая ещё до этого инцидента — там Python падал чисто, но Xvfb-обёртка не убирала за собой). Это отдельный мелкий баг, тикет на чистку повешен в backlog.

Технические детали (для интересующихся)

PR'ы и коммиты:

  • PR #276 — bash 2h stale-lockfile force-kill (12f2dfe)
  • Issue #278 — диагноз Python-зависания
  • PR #279asyncio.wait_for на 4 close-вызова в BrowserClient (2483205)

Связанные инциденты в памяти системы:
- 2026-04-29/30 — антибот pravo.tech на kad.arbitr впервые поймали и научились ретраить через new_session() (PR #269)
- 2026-05-08 — каскадные определения не терялись после первого без СРО (T1 эпик)

Корневая причина: patchright/Chromium IPC может молчать неопределённо долго, когда страница в pravo.tech-«отравленном» состоянии или сломан node-pipe (EPIPE). Без жёстких таймаутов на этой стороне любой close-вызов потенциально бесконечный. До PR #279 это была единственная незащищённая поверхность в shutdown-пути.

новая возможность

«Рулетка»: дела с несколькими СРО теперь видно на дашборде + фильтр

на 91 историческом деле появились данные о множественных СРОв карточке дела на дашборде новый бейдж 🎰в фильтре по статусу `notified` новая опция «🎰 Несколько СРО (≥2)»

Что было раньше

4 мая мы зашипили мульти-СРО («рулетка») — на определении суд иногда перечисляет 2–8 кандидатур СРО, и наша СРО может стоять не первой. Раньше система брала только первую СРО и молча теряла случаи, где наша на позиции 2–5.

Это работало для всех новых дел с 4 мая. Но для старых уведомлённых дел (до 4 мая) — мы видели в старом плоском поле sro_name только одну строку. Технически в PDF могло лежать 5 СРО, а ты видел одну. Никакого индикатора «здесь рулетка».

Ты это заметил 14 мая на деле А40-111932/2026 АО «РВС» (наше дело от 28 апреля) — там в PDF 5 СРО (ААУ ЦФОП АПК на позиции 2), а на дашборде показывалось только «ААУ ЦФОП АПК» — никакого сигнала, что это рулетка.

Что мы сделали

1. Backfill — заполнили мульти-СРО для исторических дел

Прошлись по всем 489 определениям в базе. Прочитали кэшированный PDF, прогнали через тот же экстрактор СРО, который работает в живом пайплайне с 4 мая (словарь + регекс), и записали все найденные СРО в таблицу ruling_sros с правильными позициями.

Результат:
- 361 определение уже имело мульти-СРО данные (это всё что после 4 мая — система их пишет автоматически)
- 91 определение не имело — и при пересборке нашлись СРО (от 1 до 8 на дело)
- 166 строк мульти-СРО добавилось в базу
- 35 определений прочитались, но СРО там действительно нет (отказы / возвраты без перечисления СРО — это правильное поведение)

Бэкфилл идемпотентный — повторный запуск ничего не дублирует.

2. Бейдж 🎰 в карточке дела

В колонке СРО рядом со старым тегом теперь появляется amber-бейдж, когда определение содержит ≥2 СРО:

  • 🎰 ЦФОП — №2 из 5 — если наша СРО (ААУ ЦФОП АПК) в списке. Цифра — её позиция, вторая — общее количество. Это самый сильный сигнал: «здесь несколько кандидатур, и мы среди них».
  • 🎰 8 СРО — если в списке нашей нет, но кандидатур несколько. Тоже полезный сигнал — рулетка крутится, можно зайти и побороться.

3. Фильтр «🎰 Несколько СРО (≥2)»

В выпадайке по статусу notified добавилась четвёртая опция (рядом с «Наши / без СРО» и «Чужие СРО»):

🎰 Несколько СРО (≥2)

Кликаешь — таблица сужается до дел, где в определении 2 и более СРО. На текущий момент таких дел 72.

Что ты увидишь

Открываешь denis.ownmail.dev → в шапке таблицы кликаешь Statusnotified🎰 Несколько СРО (≥2).

Или сразу по прямой ссылке: https://denis.ownmail.dev/?status=notified&sro_filter=multi_sro

72 дела, отсортированные по дате — наверху самые свежие. У каждого видно полный список СРО (наведи курсор на бейдж — увидишь tooltip) и позицию нашей среди них.

Конкретные дела, на которые посмотри в первую очередь:

Дело СРО в PDF Бейдж
А40-111932/2026 АО «РВС» (то самое, которое ты флагнул) 5 🎰 ЦФОП — №2 из 5
А56-29352/2026 3 🎰 ЦФОП — №1 из 3
А40-118403/2026 3 🎰 ЦФОП — №2 из 3
А70-7550/2026 8 🎰 8 СРО (без ЦФОП)
А60-22218/2026 5 🎰 5 СРО (без ЦФОП)
А60-21866/2026 (твой прежний «опорный пример») 4 🎰 4 СРО (без ЦФОП)

«Без ЦФОП» дела — это контекст. Мы там борьбу не ведём, но если хочешь ручную ревизию закрома — теперь можешь.

Что дальше

  • Watchpoint на 24-48 часов: новые дела продолжают писать мульти-СРО в реальном времени (так уже работает с 4 мая, не меняется). Если за неделю новые multi-SRO кейсы перестанут появляться — это сигнал, что экстрактор что-то поломал и надо смотреть. Цифра «72» на фильтре должна потихоньку расти.
  • Telegram-уведомление с позицией («(позиция X из N)») приходит только на дела, обработанные после 4 мая. Старые дела бэкфилл не ре-уведомляет — старые сообщения в Telegram остаются как были. Это намеренно: не хочу спамить тебя дублями.
  • Если в карточке дела видишь, что СРО в ruling_sros неполный (например, в PDF реально 5 СРО, а у нас в базе только 3) — это или словарь не знает редкую СРО, или регекс не справился. Скинь номер дела — добавим в словарь, бэкфилл можно прогнать повторно.

Технические детали

Раскрыть
  • Два PR'а, оба squash-merged в main 14-15 мая:
    • PR #271 «feat(sro): multi-SRO «рулетка» backfill + dashboard badge» — scripts/backfill_ruling_sros.py + SQL-подзапросы в case-list query (sros_total, target_sro_position) + amber-бейдж в dashboard.html.
    • PR #272 «feat(sro): «🎰 Несколько СРО (≥2)» filter on dashboard» — расширение sro_filter параметра до значения multi_sro.
  • Экстракция при бэкфилле — те же два слоя, что в живом пайплайне:
    • Словарь (match_all_sros) — точное совпадение по реестру известных СРО, включая орфографические варианты. Возвращает каноническое короткое имя. Источник source='dictionary'.
    • Регекс fallback (extract_all_sros_from_text) — для СРО, которых нет в словаре. Ищет паттерны «Ассоциация ...», «ААУ ...», «Союз СРО ...» и т.п. Источник source='regex'. Если та же СРО уже найдена словарём — регекс-хит игнорируется.
  • PDF-парсерpypdfium2, не pdfplumber. Pdfplumber на проде падает в этом контексте (Python 3.12.3 + importlib.metadata + inspect.getmro — поломка после миграции на 3.12). Это уже отдельная история, фикс был в PR #268 (12 мая, переключили scripts/audit_stalled_misclassified.py).
  • Идемпотентность бэкфилла: перед извлечением проверяется ruling_sros — если уже есть хоть одна строка для этого определения, бэкфилл пропускает (не хочет переписать данные, которые мог накатать живой пайплайн).
  • Перфоманс бейджа: case-list query теперь делает два дополнительных коррелированных подзапроса на каждую строку (COUNT(*) для sros_total + MIN(position) для target_sro_position). На 5500 делах это даёт +0.05с к latency, не критично. Если когда-то нужно будет сократить — можно перевести на materialized view или агрегат в самой таблице rulings.
  • Тесты: 10 на сам бэкфилл-скрипт (TDD red → green), 5 на новый фильтр, плюс пришлось патчить 4 тестовых файла с inline CREATE TABLE rulings — добавили туда CREATE TABLE ruling_sros, потому что новые подзапросы к ней обращаются. Файлы, использующие канонический init_db(), поднялись автоматически — там схема уже актуальная.
  • Codex review chain: PR #271 — r1 REVISE (gap в тестовых фикстурах) → r2 APPROVE. PR #272 — r1 APPROVE.
  • Запуск на проде: дамп cases.sqlite.backup-pre-multi-sro-backfill-20260515-040134 (62 МБ) — на случай если что-то не понравится в бэкфилле. На 7 дней оставлю на сервере.
новая возможность

OUT — email outreach pipeline V0 собран от штыка до отписки (9 из 10 тикетов)

штык 100 — двухкампанийный outbound `рулетка_v1` + `обездвижка_v1` готов запуститьSmartlead.ai Basic ($35/мес) на mailbox `denis@127-fz.com` — пивот с Coldy после блока на ₽9 400/мес тарифевся цепочка: триггер → одобрение в `/outreach/queue` → отправка → подавление → ответ → классификацияочередь PATCH-override на ручную перетегировку — когда regex ошибся на ответеосталось OUT-10 (smoke-харнесс) — последний шаг до flip'а на «по-настоящему отправляем»

Что заказывали

На звонке 5 мая ты сказал:

«давай попробуем, две штыки»

≈ 100 писем × 2 батча, чтобы посмотреть отдачу. Никакого SaaS, никакой автоматизации до сигнала — просто понять, конвертится ли холодный outbound в кейсы.

Под это собирался OUT — отдельная подсистема внутри проекта: 10 тикетов (OUT-01..OUT-10), которые превращают «есть телефон лида в outreach_contacts» в «письмо ушло, ответ классифицирован, кейс в воронке».

Что мы сделали

Pivot Coldy → Smartlead

Изначальный план был на Coldy.ai — российский cold-email-первичный сервис. На неделе они заблокировали API за самым дорогим тарифом (₽9 400/мес) + у саппорта неудобные часы. Архитектура у нас специально была сделана с swap'абельным ESP-адаптером, поэтому смена провайдера прошла без боли.

Пивотнули на Smartlead.ai Basic ($35/мес ≈ ₽3 300) — те же возможности, Inbox Rotation «из коробки» (несколько ящиков на одну кампанию), cold-permissive policy. Свой собственный mailbox denis@127-fz.com на Yandex 360 «Бизнесе» — провели полную DNS-настройку (MX/SPF/DKIM/DMARC, всё green по mxtoolbox), создали custom tracking-домен emailtracking.127-fz.com, две кампании-заготовки уже там.

9 тикетов выкачены отдельными ветками на GitHub

Каждый тикет проехал по протоколу Codex review → fixes → APPROVE → commit + push. Ни одна ветка ещё не вмержена в main — это для твоего ревью на GitHub.

Тикет Ветка на GitHub Что внутри
OUT-01 feat/out-01-schema-migration Схема: 3 новые таблицы (outreach_candidates, outreach_suppression, outreach_send_attempts) + ALTER на старых
OUT-02 feat/out-02-smartlead-esp Smartlead ESP-адаптер: send, get_lead_status, error taxonomy + контроль OutreachConfig
OUT-03 feat/out-03-candidate-builder YAML-конфиг триггеров config/outreach_sources.yaml + рендер кандидатов
OUT-04 feat/out-04-queue-ui Страница /outreach/queue для тебя: 5 действий (Approve/Reject/Edit/Snooze/Bulk approve до 100 за раз)
OUT-05 feat/out-05-sender-cron Sender cron: gate подавления → daily cap → pacing → idempotency → отправка через ESP
OUT-06 feat/out-06-unsubscribe-stats RFC 8058 one-click unsubscribe /u/{key}/{token} + поллинг bounce/блок-статусов из Smartlead
OUT-07 feat/out-07-reply-poller Yandex IMAP-поллер: тянет UNSEEN ответы, классифицирует regexом (positive/negative/unclear), пишет в outreach_messages.reply_classification
OUT-08 feat/out-08-funnel-ui Воронка /outreach/funnel — когорты × source × template × variant за 30 дней
OUT-09 (doc-only) DNS-runbook + warmup-чеклист

Что нового на дашборде

Когда смержим ветки в main и задеплоим:

  • /outreach/queue — твоя главная страница для outbound. Видишь pending-кандидатов, читаешь черновики, одобряешь либо отклоняешь. Bulk-approve кнопка («штык 100» одним кликом).
  • /outreach/funnel — built → approved → sent → delivered → opened → replied → positive — с фильтрами по source/template/variant. Видишь, какой шаблон работает.
  • /u/<key>/<token> — публичная страница отписки. Когда лид жмёт «отписаться» в письме, попадает сюда → суппресс-роу + русская страница «Отписка зарегистрирована» с упоминанием ст. 18 ФЗ-152.
  • PATCH /api/outreach/messages/{id}/reply_classification — когда regex угадал неправильно, ты в очереди жмёшь «изменить ярлык» (positive ↔ negative ↔ unclear).

Защитные слои

В OUT-05 sender реализованы семь стадий перед каждой отправкой:

  1. Malformed-guard — если в кандидате нет email или тело пустое → failed, без вызова ESP.
  2. Suppression gate (первая) — если email/domain/INN уже в outreach_suppressionsuppressed, без вызова ESP. Это срабатывает ПЕРЕД дневным капом и pacing'ом — отписки приоритетнее.
  3. Daily cap — если за UTC-сутки уже отправлено OutreachConfig.daily_global_cap (по умолчанию 200) → кандидат остаётся approved (отложен на следующий тик), без отправки.
  4. Pacing — задержка между кандидатами (30 сек по умолчанию). В рамках лимита Smartlead Basic 50 req/min.
  5. Idempotency claim — INSERT в outreach_send_attempts с ключом outreach-c<id>-a<retry>. UNIQUE-индекс ловит дубликаты, если cron перезапустится в неудачный момент.
  6. Resolve campaign_id — берёт OutreachConfig.smartlead_campaign_id_<source>. Если None и адаптер не manual_copyfailed.
  7. ESP send — Smartlead. На 2xx → пишем outreach_messages, статус кандидата → sent. На отказ → бамп retry_count. На третьем фейле → failed (terminal).

Плюс kill-switch: OutreachConfig.auto_send_globally_enabled=false коротко замыкает всё ещё до фетча кандидатов — на случай ЧП.

Reply-классификатор знает русские падежи

Тонкость: «не интересно» содержит подстроку «интересно» — regex без правил отнёс бы это к positive. Поэтому отрицательный regex проверяется первым:

  • не\s*(интересн|пиш|нуж|хоч) или отпи[сш] / удалит / спам / стопnegative
  • Если негатив не сработал, интересн / давайте обсуд / свяж / готов(ы|а)? / подробнpositive
  • Иначе → unclear

Класс отпи[сш] покрывает обе формы — инфинитивный корень («отписаться») и повелительный («отпишите меня»). Слова отпис/удалит дополнительно триггерят авто-suppress — суппресс-роу прилетает сразу, без твоего вмешательства. спам/стоп относятся к negative, но автомат не давит — это спорные сигналы, ты решаешь руками через PATCH-override.

Smartlead — что прямо сейчас

Smartlead Basic         ✅ оплачен с корп-карты Ivinco
mailbox denis@127-fz.com ✅ SMTP+IMAP green, warmup ACTIVE
DNS                      ✅ MX/SPF/DKIM/DMARC verified mxtoolbox
tracking domain          ✅ emailtracking.127-fz.com → Smartlead
campaign rouletka_v1     ✅ id 3325689, DRAFTED, mailbox linked
campaign obezdvizhka_v1  ✅ id 3325690, DRAFTED, mailbox linked
warmup-фаза              🟡 14 дней (стартовала 2026-05-12)

14-day warmup — Smartlead будет нагревать ящик от ~5 писем/день до ~50/день. Это нужно чтобы Gmail/Yandex/mail.ru видели рост репутации постепенно, не пометили как спам. Через 14 дней (около 2026-05-26) — flip на active sends.

Что осталось до запуска

OUT-10 (последний тикет)

Smoke-харнесс: end-to-end проверка по всем 10 шагам спеки. Прогон в shadow-mode (send_adapter='manual_copy') на dev-БД, потом один тестовый send живьём на твой адрес, чтобы убедиться что цепочка не разорвалась. Шипну в течение недели.

Твои шаги, когда warmup закончится

  1. Залить SQL триггеров в config/outreach_sources.yaml — какие именно кейсы должны попадать в рулетка_v1 (no-SRO leads) и обездвижка_v1 (stalled cases). Сейчас там stubs.
  2. Заполнить тело шаблонов в БД — outreach_templates для cession_offer@v2 и т.п. Голос — твой; я могу помочь черновиком.
  3. Дать команду flip-кнопкеOutreachConfig.auto_send_globally_enabled=true в config.yaml на Hetzner + рестарт web-сервиса. После этого Approve в очереди начинает реально отправлять.
  4. Первая «штык 100» — ты заходишь в /outreach/queue?status=pending, читаешь черновики, жмёшь Bulk Approve. Sender cron в течение 5-10 минут (pacing=30s × 100 = ~50 мин) отправит всё через Smartlead.

Технические соображения

Multi-branch вместо одного epic-merge

Все 9 тикетов лежат отдельными ветками на origin. Можно мержить по одной в порядке OUT-01 → OUT-02 → ... → OUT-08 (или одной большой PR'кой с тага OUT-07, который содержит все предыдущие в истории).

IMAP пароль уже на Hetzner

YANDEX_IMAP_PASSWORD положен в /opt/scrapers/bankruptcy-monitor/.env сегодня. Залогинились — OK LOGIN Completed, 5 сообщений в INBOX (вероятно, тестовые от Smartlead warmup). Когда OUT-07 cron активируется, эти 5 классифицируются как unclear (нет матча в outreach_messages — мы пока ничего не отправляли) → orphan-skip, marked SEEN, без записей в БД. Чистая разминка.

Метрики проекта

Тикетов отгружено 9 из 10
Веток на GitHub 8 (плюс OUT-09 — doc-only)
Коммитов на ветках ~50 (с учётом промежуточных Codex-итераций)
Тестов написано ~300+ (suppression, classifier, poller, route, sender, smartlead-esp, unsubscribe)
Внешних сервисов Smartlead Basic + Yandex 360 Бизнес + GoDaddy (домен)
Прямых трат проекта $35/мес Smartlead + $1/мес домен 127-fz.com
Доделать до запуска OUT-10 smoke + 14-day warmup
новая возможность

CP-5: единый контракт для всех источников данных + replay-кнопка в /ops

Все 4 источника (kad.arbitr, fedresurs, pb.nalog, egrul) теперь живут под одним контрактом BaseSourceЦветные бейджи здоровья на /ops/sources — видно сразу, кто работает, кто упал, кто молчитКнопка «Replay» на каждом событии в /ops/sources/{name} — если что-то странно сработало, можно пересчитать одним кликом без операторских скриптовCanary на kad.arbitr в shadow-режиме параллельно с прод-монитором — 24 часа наблюдения, потом решаем

Что было раньше

У нас 4 источника данных: kad.arbitr (определения суда), fedresurs (намерения о банкротстве), pb.nalog (налоговая задолженность через «Прозрачный Бизнес»), egrul (ЕГРЮЛ). Каждый когда-то писался отдельно — kad самый старый и боевой, egrul самый новый.

Проблема, которую ты сам не видишь, но которая ест время:

  • У каждого источника свой стиль ошибок. kad может «лечь» из-за DDoS-Guard, fedresurs из-за Qrator, pb.nalog из-за CAPTCHA, egrul из-за лимита запросов. Каждый раз когда что-то падает, я лезу в логи и должен помнить, как именно этот конкретный источник обозначает «упал».
  • Если по конкретному определению пришёл странный результат (например, СРО не та или ничего не нашли), нет простого способа «пересчитать с нуля». Нужно лезть в БД и крутить SQL.
  • Не было видно, что вообще происходит. Все 4 шли через cron-скрипты, и единственный показатель здоровья — «есть ли свежие записи в БД». Если источник молчит — это потому что он сломался или потому что просто новых дел нет?

Это всё операционка, которая копится. Каждый раз когда мы добавляли новую функцию (вроде EN-cascade недавно), приходилось решать вопросы заново для каждого источника.

Что мы сделали

Большой рефакторинг — внутренний, тебя в день-в-день он не должен задевать. Но базу под следующие фичи положили.

1. Единый контракт для всех источников

Теперь все 4 источника живут под одним «договором»:

Метод Что делает Когда срабатывает
pull() Тащит сырые события из внешнего API Каждый раз когда cron запускается
parse() Превращает сырое событие в нашу структуру Сразу после pull, без I/O
persist() Записывает в БД Если parse не отбросил событие
replay() Пересчитывает уже сохранённое событие через текущую логику По кнопке в UI или вручную
health_check() Проверяет, жив ли источник По расписанию или по запросу

Это значит — когда мы в следующий раз будем менять, например, регекспы для извлечения СРО, я не лезу в 4 разных файла, а правлю один. И тесты автоматически прогоняются через все 4 адаптера.

2. Replay-кнопка на /ops/sources/{name}

На страницах /ops/sources/kad.arbitr, /ops/sources/fedresurs_spa, /ops/sources/pb.nalog, /ops/sources/egrul.nalog теперь видны последние 50 событий — что было сделано, когда, с каким результатом. Рядом с каждым событием — кнопка «Replay».

Зачем это нужно: если ты когда-нибудь заметишь, что по конкретному определению пришёл странный результат (например, парсер посчитал что СРО ЦФОП АПК, а ты вручную посмотрел — там МСРО Содействие), нажми Replay. Система пересчитает событие с текущей версией логики. Если регекспы или dict обновились — увидишь новый результат сразу. Без меня и без SQL.

3. Цветные бейджи здоровья на /ops/sources

На общей странице /ops/sources теперь у каждого источника видна цветная плашка:

  • 🟢 зелёный (ok) — источник отвечает, латентность нормальная
  • 🟡 амбер (degraded) — отвечает, но медленно или с ошибками (часто = CAPTCHA, временный rate-limit)
  • 🔴 красный (failed) — упал, нужен оператор
  • серый (unknown) — нет данных о состоянии (например, мы ещё не делали health-check, или у источника нет дешёвого ping-эндпоинта как у EGRUL)

Это полезно когда я не у компьютера: ты сам можешь зайти и увидеть, что красное — это уже сигнал «звони Юджину».

4. Canary на kad.arbitr в shadow-режиме

Самая аккуратная часть. Старый монитор по kad.arbitr продолжает работать как обычно — ничего не меняется. Параллельно с ним мы запустили новый адаптер в shadow-режиме: он каждые 15 минут делает ровно то же самое, что старый монитор, но в БД ничего не пишет. Только сравниваем результаты.

Цель: 24 часа наблюдать, не разъезжается ли новый адаптер со старым. Если разъезжается больше 5% циклов — откатываемся, разбираемся, чиним. Если в пределах нормы — следующим шагом промоутим новый адаптер на место старого. Это уже отдельная задача после 24-часового окна.

Этот же подход применим к fedresurs/pb.nalog/egrul, но мы их пока не canary'им — у них тестовое покрытие через сравнения и контракт-харнесс, а risk-budget для shadow-сравнения на проде мы тратим аккуратно, по одному.

Что ты увидишь

Сегодня вечером и завтра

  • На /ops/sources появятся цветные бейджи у kad.arbitr / fedresurs_spa / pb.nalog / egrul.nalog. На старте часть может быть серой — это норма, после первого health-цикла обновится.
  • Зайдёшь на /ops/sources/kad.arbitr — увидишь таблицу последних 50 событий и кнопки Replay. Можно тыкать — это безопасно, не сломает прод. На каждый клик в БД остаётся след «replay started → success/failed», я смогу разобрать ретроспективно если что-то пойдёт не так.

В ближайшие 24 часа

Каждые 15 минут срабатывает canary по kad.arbitr — пишет одну запись в pipeline_events с пометкой step='canary'. Через сутки я гляну логи и:

  • Если расхождений со старым монитором < 5% → планируем замену старого монитора новым адаптером (это будет следующий пост).
  • Если ≥ 5% → откатываю canary, разбираюсь почему.

По остальным трём источникам

fedresurs / pb.nalog / egrul пока работают по-старому — старые скриптовые цепочки никто не трогал. Новые адаптеры готовы и протестированы, но включим их в прод следующим этапом, по одному, через тот же shadow-режим, чтобы не рисковать.

Что дальше

Через 24 часа: ретро по kad.arbitr canary, решение по flip-to-primary.

Следующая неделя: если kad.arbitr canary прошёл — повторяем для fedresurs / pb.nalog / egrul в той же последовательности.

Параллельно: теперь когда платформа есть, проще будет добавлять новые источники когда они нам понадобятся (например, ЕФРСБ через платный API, если решим, что оно того стоит — отдельный разговор).

Технические детали

Что включает CP-5 эпик 10 sub-issues, итог: - **CP-5-01:** BaseSource ABC + Pydantic v2 types + replay taxonomy preflight - **CP-5-02:** Source registry auto-discovery + adapter-presence badge - **CP-5-03:** Conformance test harness (общий тестовый контракт для всех адаптеров) - **CP-5-04:** kad.arbitr adapter migration — самый pain-prone источник, под него ABC окончательно зафиксировали - **CP-5-05:** Replay dispatcher endpoint с content negotiation (HTTP API + UI fallback) - **CP-5-06:** fedresurs_spa adapter migration (SPA publications stream) - **CP-5-07:** pb.nalog + egrul.nalog adapter migrations split (две лёгкие миграции одним тикетом) - **CP-5-08:** Replay button UI на /ops/sources/{name} detail page - **CP-5-09:** Health display + cross-adapter replay taxonomy validation - **CP-5-10:** Canary cron + scope-boundary regression gate — закрытие эпика **Тесты:** `pytest -m sources` 117 → 294 (+177 net new). `pytest -m sources_conformance` 31 → 199 (+168 — это conformance-харнесс, прогоняет общий контракт через все 4 адаптера). mypy --strict + ruff clean на всех CP-5 путях. **Review chain:** 4 из 10 тикетов прошли через Codex review с REVISE → APPROVE циклом (5 substantive findings caught и зафиксированы pre-merge: run_step status semantics, частные методы провайдера, EGRUL health probe quota concern, taxonomy test vacuity, persist exception swallowing). Остальные 6 прошли APPROVE с первого захода. **Architecture decisions locked:** - Per-adapter drop-reason ownership: CP-5-01 владеет только `replay.started/.success/.failed`; каждый адаптер сам добавляет свои `.` коды в CP-2 taxonomy. - CP-2/CP-3 schemas — LOCKED, новых ALTER нет. - _CatalogScraperBase preserved untouched — fedresurs адаптер обёртывает FedresursPublicProvider + PublicationStreamScraper, не _CatalogScraperBase. - Opt-in health probe pattern (EGRUL): default `unknown` со status detail вместо реального fetch, чтобы не палить квоту/CAPTCHA на cold-start. Оператор может явно включить live probe через `enable_live_health_probe=True`.
Push state + GitHub close-out Все 10 commits на `origin/main`:
1361795 cp5-10  canary cron + scope-boundary gate
52c5d66 cp5-09  health-status display + replay taxonomy validation
0b4d3da cp5-07  pb.nalog + egrul.nalog adapter migrations
a90e984 cp5-06  fedresurs_spa adapter migration
e6cb42c cp5-08  replay button UI
b42a409 cp5-05  replay dispatcher endpoint
82b1976 cp5-04  kad.arbitr adapter migration
7f87772 cp5-03  conformance test harness
8f59031 cp5-02  source registry auto-discovery + badge
d1b72fb cp5-01  BaseSource ABC + Pydantic v2 types
GitHub Project #6: epic #188 + sub-issues #238-#247 закрыты как Done.
новая возможность

Восстановили мониторинг + новый слой проверки «жирных» через DaData (EN)

Telegram-алерты возобновились — мониторинг был в простое 14 часовновый источник для классификации должников — DaData identity layer152 кейса с пометкой `inn_not_in_db` пересобраны: 109 — живые ООО, которые мы зря пропускалипосле 24-часового наблюдения за стабильностью кэша — операторская команда поднимет 109 кейсов в `watching`

Что было раньше

Ты заметил, что с раннего утра 8 мая Telegram молчал — никаких новых уведомлений. Это не потому что нечего слать. Мониторинг упал в 06:23 UTC и 14 часов подряд каждый 15-минутный тик crash-ился на старте — браузер не мог открыть kad.arbitr.ru.

Когда я начал разбираться, всё выглядело как «прокси умер»: сайт Evomi у тебя не открывался, мне казалось — провайдер лёг. Но проверки показали другое:

  • Evomi отвечал HTTP 200 за 122 мс на запрос их собственного сайта
  • curl через тот же Evomi-прокси открывал kad.arbitr.ru без проблем (TLS handshake, 200 OK)
  • А Chrome через тот же прокси падал с ERR_TUNNEL_CONNECTION_FAILED

Прокси работал. Падал именно браузер.

Параллельно у нас была другая задача — новый слой проверки должников через DaData (EN-cascade). Когда наша основная база bo.nalog возвращает 404 на ИНН (а это бывает у банков, страховых, недавно открытых ООО), мы помечали кейс как inn_not_in_db и забывали. Получалось, что жирные ACTIVE-юрлица тихо проваливались мимо, и ты их не видел в воронке. Этот слой был готов, но не «прожжён» в проде до сегодня.

Что мы сделали

1. Починили мониторинг (P0)

Корень проблемы оказался хирургическим: библиотека playwright-stealth, которую мы накладывали поверх patchright (наш undetected-движок). Patchright инжектит скрипт через свой внутренний URL patchright-init-script-inject.internal. С прокси на уровне браузерного контекста этот internal-запрос шёл через Evomi — а Evomi (как и любая residential-сеть) не умеет резолвить такие нерутируемые *.internal хосты. Каждый запрос ронялся, а с ним — вся загрузка страницы.

Patchright и так stealthed — двойной патчинг был антипаттерном. Убрали playwright-stealth из обоих клиентов (kad-monitor и casebook-discovery). Воспроизвели на проде минимальным репро и подтвердили — без stealth Chrome через Evomi проходит DDoS-Guard и открывает kad.arbitr.ru за 7 секунд.

Результат: прод восстановился в 03:37 UTC. Накопившиеся 488 кейсов сейчас догоняются. К утру 9 мая по Москве Telegram опять должен зашевелиться.

2. Активировали DaData identity layer

Логика проста: если bo.nalog не нашёл ИНН — спроси у DaData. Их ответ говорит:

  • ИНН относится к ACTIVE юрлицу (ООО/АО/ПАО, не ИП и не физлицо) → мы зря его пропускали, надо мониторить
  • ИНН найден, но юрлицо в LIQUIDATED / BANKRUPTCY / UNKNOWN → это уже не наша целевая аудитория, исключаем явно
  • ИНН не найден вообще → оставляем как было, на будущий слой (web-search, PDF-parse)

3. Прогнали репетицию по существующим 152 кейсам

Это всё кейсы, помеченные inn_not_in_db за последние 4 месяца — из тех, что когда-то прошли первичный фильтр, но провалились на финансовой проверке.

Что нашли Сколько Что значит
Живое ACTIVE юрлицо 109 (71,7%) Мы зря их откладывали — это потенциально твои клиенты
Юрлицо в ликвидации/банкротстве/архиве 35 (23%) Корректно отфильтрованы — теперь явно помечены
Не найдено вообще 8 (5%) Оставили как было — задел на следующие слои
Ошибки запросов 0

Подчеркну: 109 кейсов — это потенциальные сделки, которые ты бы не увидел старой логикой. Прогон сделан в режиме «только посмотреть» (dry-run) — пока никаких изменений в базе нет.

Что ты увидишь

Сегодня вечером и завтра утром (МСК)

  • В Telegram пойдут уведомления, которые накопились за 14 часов простоя — это backlog мониторинга, а не новые кейсы. Ничего не теряется, всё просто отложилось.
  • На дашборде «watching» цифра колыхнётся вниз: за 14 часов часть кейсов ушла в notified/returned/rejected без Telegram, и теперь обработается пакетно.

Через ~24 часа (после стабильного наблюдения)

Я запущу второй прогон по тем же 152 ИНН в режиме «применить» — и 109 «жирных» кейсов перейдут в watching с отметкой «найдено через DaData identity». Дальше они работают как обычные watching-кейсы: monitor каждые 15 минут проверяет, не появилось ли определение, и шлёт тебе Telegram если СРО подходит.

На дашборде

  • В «Сkipped» появится новая под-категория inn_not_in_db_dadata_inactive (35 кейсов) — это явно мертвые юрлица, чтобы ты не путал их с настоящими «не нашли»
  • В «Watching» — новые 109 кейсов с пометкой qualified_by_dadata_identity

Что дальше

Ход 1 (через 24h): запуск backfill в режиме --apply. Тебя дёрну в Telegram когда буду готов — глянешь dry-run output (109 имён) на разумность.

Ход 2 (через 7 дней): замер реального recovery rate. Если из 109 в watching-статусе у нас будет приличная конверсия в notified (например, ≥30%) — слой DaData оправдал себя, движемся к следующему: web-search для тех 8% которые DaData не нашла.

Если recovery rate окажется низким (< 30%) — добавим следующий слой (Tavily / Perplexity web-search) для этих 8 «оставшихся». Это уже отдельный эпик.

Технические детали (для тех, кому интересно)

Stealth-фикс:

  • 2 коммита: e608093 (kad-monitor + casebook-discovery), fced0e4 (UX-фиксы recascade-скрипта)
  • Минимальный репро прошёл через 3 вариации: только browser-level proxy, context-level proxy без stealth, context-level proxy + stealth — последний воспроизвёл ERR_TUNNEL точь-в-точь как прод
  • Codex (cross-family review) APPROVE r1 на stealth-fix, REVISE r1 → APPROVE r2 на recascade-UX-фиксы
  • В follow-up: _on_request_finished callback в client.py пишет coroutine 'Request.sizes' was never awaited warning — patchright API стал async, callback нужно asyncronize-ировать. Не блокер.

EN-cascade architecture:

  • 5 слоёв: bo_nalog_cachedtax_dbtax_db_namebo_nalog_livedadata (новый)
  • Колонка cases.latest_enrichment_layer пишется на каждом ходе пайплайна — теперь видно через какой слой решение принято
  • Кэш DaData в dadata_enrichment_cache (TTL: hit 7d / not_found 30d / rate-limited 1h)
  • Дневная квота 900 запросов (платный тариф $50/мес)
  • Anti-spoof: проверяем что DaData вернула тот же INN что мы спросили (защита от случая, когда они «исправили опечатку» и вернули соседнее юрлицо)

Скрипт scripts/recascade_inn_not_in_db.py:

  • Опции: --dry-run (по умолчанию), --apply, --resume-from <инн> (+ --resume-from-kad-id для точного crash-resume), --wait-for-monitor, --confirm-large-batch для > 200 кейсов
  • Pre-check: блокирует запуск если monitor cron занят (через pipeline_runs в observability DB)
  • Контрольная точка: data/backfill_state/recascade_<run_id>.json — composite cursor (debtor_inn, kad_id) чтобы при крэше не пропустить same-INN siblings
  • Fail-loud если DADATA_API_KEY не загружен — иначе все 152 кейса «дренировали бы тихо», как в первом прогоне сегодня

Тесты: 23 кейса для recascade + 95 EN-suite full coverage. ruff + mypy --strict clean.

Чего НЕТ в этом релизе (отложено):

  • Web-search слой (Tavily/Perplexity) — после замера recovery rate через 7 дней
  • PDF deep-parse слой — после web-search
  • UI: пока новые qualified_by_dadata_identity появятся в Watching обычным фильтром, отдельной плашки нет
новая возможность

Event sink + Sentry: каждый шаг каждой задачи теперь виден (CP-2)

новая таблица `pipeline_events` в `data/observability.sqlite`5 чокпоинтов инструментованы (catalog scraper, intention sweep, LLM, provider health, refresh-оркестраторы)Sentry SaaS подключён (DSN на Hetzner, PII-скраббер локально)суточная ретенция — 90 дней событий, 365 дней пробеговстраница `/ops/runs/{id}` готова к расширенному виду из CP-3

Что было раньше

Предыдущие шаги дали два слоя видимости:

  • CP-1 — реестр запусков. Таблица pipeline_runs, страница Ops · Runs. Видно когда стартанул cron_monitor или intention_stream, сколько шёл, упал или нет.
  • CP-3 — админ-страницы поверх реестра. Расписание (Ops · Jobs), реестр источников и расходов (Ops · Sources), заглушка под детальный пробег /ops/runs/{id} с amber-плашкой «Per-event drill-down requires CP-2 ship».

Дыра в середине: «а что задача внутри себя сделала?» Прошёл ли intention-sweep по 700 компаниям? Сколько раз LLM вызывался и зачем? На каком шаге catalog-скрейпер уперся в Qrator-блок? Был ли это уже третий fetch_timeout подряд? — Всё это жило в логах в /var/log/bankruptcy-monitor/*.log. Чтобы что-то найти — grep-ать на сервере. Структурированного «а сколько у нас было intention.fetch_fail за вчера» — не было.

Плюс ошибки: если cron упал — в pipeline_runs.status='failed', exit_code какой-то, и всё. Стектрейс — найди его в логе. Никаких алертов.

Что мы сделали

Восемь подзадач, все на проде. Эпик #185 закрыт.

1. Таблица событий

Новая pipeline_events в data/observability.sqlite (отдельный файл от cases.sqlite — чтобы запись событий не конкурировала за блокировку с основной БД). 16 колонок: run_id (linkage с pipeline_runs), source/step/action (saved/dropped/errored/started/finished), reason_code (catalog.qrator_block, llm.over_budget, etc.), payload_hash + payload_size, error_msg, created_at, seq для упорядочивания внутри пробега.

Реестр валидных reason_codes — отдельный файл src/observability/event_reasons.py с 20 кодами, по семантическим доменам: catalog.* (6 кодов), intention.* (2), llm.* (8), provider.* (2), run.* (2). Любой неизвестный код в dev-режиме крашит ассерт; в проде — логируется в stderr и пропускается (fail-open).

2. Hot-path API

Функция track_event(...) синхронная, async-safe by construction: на горячем пути только in-memory append + микросекундный лок, ноль I/O. Воркер-тред каждые 200мс / 100 событий / на severity=high сливает буфер в SQLite одним батч-инсертом. Если процесс падает — atexit-хук сливает оставшееся.

run_step — контекстный менеджер обёртка вокруг произвольного блока кода — автоматически парные started/finished или started/errored события, замеряет latency_ms, ловит исключения.

3. Sentry init с PII-скраббером

init_sentry() в src/observability/sentry_init.py. Lazy import — sentry-sdk не подгружается если DSN не настроен. На инициализации: дефолтные интеграции отключены (никакие фреймворковые хуки не успеют схватить тело request'а до нашего скраббера); единственный хук — before_send, который:

  • проходит по всему event (extra / contexts / tags / request / user / breadcrumbs / stacktrace.vars) и заменяет значения по ключам, матчящим PII-регекс — phone|email|inn|ogrn|kpp|passport|address|fio|otchestvo|surname|familia плюс кириллические эквиваленты фио|отчество|адрес|инн|кпп|огрн|телефон|почта|паспорт|счет|фамилия — на [REDACTED]
  • считает стабильный fingerprint = SHA-256 от (exception_type, message[:200], top-3 stack frames «module::function») и режет до 10 событий per-fingerprint per-час. Sentry-шный event_id — UUID4 на каждое срабатывание, для группировки бесполезен; наш fingerprint склеивает повторы из одного места кода

Результат: даже если кто-то случайно сунет ИНН в logger.info(...) — в Sentry улетит [REDACTED]. И если runaway-цикл будет генерить одно и то же исключение 1000 раз в минуту — Sentry получит ровно 10 за час.

4. Ретенция

Суточный cron 35 4 * * * запускает scripts/cron_observability_retention.sh → удаляет события старше 90 дней и пробеги старше 365 дней. Пишется log в /var/log/bankruptcy-monitor/observability_retention.log. Если data/observability.sqlite залочен — таймаут 60с (busy_timeout PRAGMA), потом лог + следующий тик попробует снова.

5. Инструментация: 5 чокпоинтов

Места, где пишется событие на каждое решение — расставлены по «горячим» точкам пайплайна:

  • catalog-скрейпер (_CatalogScraperBase): на каждой странице — started; на каждой строке — saved (записалось) или dropped catalog.freshness_unchanged (запись не изменилась); на ошибках — errored catalog.qrator_block / catalog.fetch_timeout / catalog.parse_error; при попадании в watermark — finished catalog.watermark_caught_up; при date_from-cutoff — dropped catalog.before_date_from
  • intention-sweep: started / finished бэндят сам пробег; errored intention.fetch_fail на каждый failed snapshot fetch или per-guid publication fetch; dropped intention.filter_miss если discovery-filter отказал кейсу
  • LLM-обёртка (call_with_audit): 11 точек — errored llm.api_key_missing / llm.over_budget / llm.prompt_template_error / llm.openrouter_exception / llm.parse_failed; dropped llm.confidence_low / llm.captcha_unsupported. Успешный вызов НЕ пишет в pipeline_events — для этого есть llm_extractions (cost, latency, raw_quote)
  • provider health: errored provider.degraded только в момент пересечения порога (когда threshold впервые крестится за час). Single-probe failure → пишется только в provider_health_log, в pipeline_events не дублируется
  • refresh-оркестраторы (refresh_catalog, refresh_cession_matches): started / finished на каждый пробег; errored run.early_abort если что-то упало до основной работы (например, BrowserSession.__aenter__ не смог поднять браузер)

6. Бенчмарк под нагрузкой

Перед деплоем — scripts/benchmark_observability_writes.py: на копии observability.sqlite запускается синтетическая нагрузка: Worker A симулирует cron-tick раз в 5 секунд, Worker B пишет 4 потока × 250 событий в секунду, Worker C читает. Замеряется p50/p95/p99 latency для pipeline_runs И pipeline_events. Verdict-функция: ratio > 1.5 → BLOCK; > 1.0 → APPROVE_WITH_CAVEATS; ≤ 1.0 → APPROVE. На бейслайне: APPROVE.

Что ты увидишь

В верхнем меню как было — три Ops-кнопки. Визуально на дашборде ничего не поменялось.

Под капотом — на текущий момент уже 137+ событий в pipeline_events за первый час после деплоя (события набегают на каждый cron_intention_stream, cron_p5_bl_* и cron_monitor тик). Каждое событие имеет run_id, который джойнится с pipeline_runs.id для того же пробега — это означает, что когда CP-3-страница /ops/runs/{id} подключит этот источник, на ней будет live-таймлайн.

Sentry уже подключён. Тестовое событие проверено — пришло в дашборд с правильным release (git-SHA), environment=production, тегом smoke_test=true чтобы фильтровать «не это», и со скрабленным контентом. Если что-то реально упадёт в проде — увидишь его в Sentry как Issue с email-нотификацией.

Что дальше

  • CP-3-страница /ops/runs/{id} уже шипалась с amber-плашкой «Per-event drill-down requires CP-2 ship». Эта плашка теперь устарела — следующий шаг (отдельный мини-эпик) подключает источник pipeline_events к шаблону страницы и рисует гистограмму типов событий + raw-таймлайн. Спека готова, в очереди.
  • Бейслайн lock-contention на cases.sqlite снят перед деплоем: p95=0ms, max=8ms, 0 таймаутов на 100К попыток за 30 секунд. Через 7-14 дней под реальной нагрузкой пересниму и сравню — если p95 уйдёт за 50% бейслайна, значит пишущий воркер событий бьётся за блок с основной БД (хотя они в разных файлах — поводов для контеншна не должно быть).
  • Бэкапы data/observability.sqlite.backup-pre-cp2-20260508T182456Z (184КБ) + cases.sqlite.backup-pre-cp2-... (52МБ) лежат на Hetzner на случай отката. Снять можно после 15 мая.
  • Sentry квота — Developer-план, 5К событий/мес. С нашим per-fingerprint rate-limit (10/час/класс) этого хватит на ~50 разных классов ошибок постоянно работающих. Если упрёмся — апгрейд на Team-план $26/мес.

Технические детали (для интересующихся)

Раскрыть
  • 8 sub-issues в Project #6: #199 (schema + reason registry) → #200 (track_event API + buffered writer) → #201 (Sentry init + PII-скраббер) → #202 (retention cron) → #203 (catalog scraper instrumentation) → #204 (chokepoints #2-5) → #205 (write-pressure benchmark) → #206 (Hetzner deploy)

  • Эпик #185 закрыт. Около 16 коммитов на main, ~5800 строк диффа.

  • 3624 теста в полном наборе (с 3461 до старта инструментации — ~+160 за весь эпик: 26 boundary-тестов на CP-2-06, 12 contract-тестов на CP-2-05, плюс параллельная работа T1/EN-каскада которая мерджилась в тот же день)

  • Архитектурные решения:

    • Отдельный файл data/observability.sqlite — чтобы события не конкурировали за write-lock с cases.sqlite (где монитор и discovery работают каждые 15 минут)
    • Sync track_event() async-safe by construction: лок только на in-memory deque append (микросекунды), I/O — в отдельном демон-треде. Кейс «awaitable из sync» решается через asyncio.run_in_executor если нужно — но в горячем пути не нужно, append — 200ns
    • payload НЕ хранится — только SHA-256 хэш + размер + truncated-флаг. Если когда-нибудь понадобится посмотреть содержимое — есть hash для перекрёстной ссылки на лог. Это решение под GDPR/272.1 УК — даже если в pipeline_events случайно попадёт ИНН в payload, он туда не запишется
    • severity='high' форсит флэш — для errored событий, после которых процесс может упасть. Иначе буфер мог бы потеряться при крэше. На обычных saved/dropped — обычная буферизация
    • Default integrations OFF в Sentry: default_integrations=False, integrations=[]. Это значит excepthook НЕ установлен — необработанные исключения НЕ попадают в Sentry автоматически. Решение: ловить руками через try/except → capture_exception(). Surfaced в smoke-тесте (первая версия sentry_smoke.py ожидала автокапчуа — не работало)
    • source_event_id как идентичность для catalog-событий — позволяет CP-3-странице делать event_id-based drill-down («покажи всю историю этого этп-объявления»). Не уникальный (одно объявление может пройти через сейв и потом зафейлить parse) — но удобный для группировки
    • Locked Decision 2: per-row hot-path emission OKsaved событие на каждый rc>0 upsert. На бэкфилле 1000 страниц × 50 строк = 50К событий за пробег. Лок-микросекундный append + батч-фласх каждые 200мс — выдерживает без проблем. Verified бенчмарком CP-2-07
  • Per-task review chain (Codex r1 → revise/APPROVE → Gemini SHIP):

    • CP-2-01..05, 07: Codex APPROVE на r1 (с одним-двумя NIT-фиксами)
    • CP-2-03: Codex REVISE на r1 — предложил Cyrillic PII-регекс отдельно; на r2 APPROVE
    • CP-2-06: 4 раунда (r1 REVISE → r2 REVISE → r3 REVISE → r4 APPROVE) — каждый раунд Codex поднимал планку покрытия тестов: «эти тесты вызывают track_event() напрямую и не проверяют что production-код вообще зовёт track_event()». Финальный набор — 26 boundary-тестов с реальным driving кода
    • CP-2-08: Codex r1 APPROVE; runtime — surfaced 1 баг в smoke-скрипте + 1 баг в env var bridge (см. ниже)
  • Surfaced+fixed mid-deploy: после миграции на проде 137 событий за первый час, но run_id у всех NULL. Cause: CP-1-обёртка _run_step.sh экспортит _RUN_STEP_ID, CP-2-функция track_event() ищет PIPELINE_RUN_ID. Имена не координировались между спеками. Фикс — двустрочный bridge в _run_step.sh: export PIPELINE_RUN_ID="$_RUN_STEP_ID"; export PIPELINE_JOB_NAME="$_RUN_STEP_JOB_NAME". После пуша + ручного триггера intention_stream — 2 новых события с run_id=52f03f757d74..., джойнящимся с pipeline_runs.id. Lesson для будущих эпиков: env var names — это контракт между специками, валидировать явно

  • Smoke-контракт (7 итемов из спеки § CP-2-08, все ✅):

    • pipeline_events count > 0 после первого тика — да, 137
    • run_idpipeline_runs.id — да, после env-bridge фикса
    • Sentry дашборд показывает test exception — да, event_id 15b0755a3c2244c5...
    • Retention cron status='ok' — да, ручной --apply (d10651e8..., 335ms, 0 удалений)
    • Routes 200 OK — да, кроме pre-existing /costs/sources 503 (pending ProviderHealthMonitor cron, не относится к CP-2)
    • Lock contention p95 в пределах 50% бейслайна — да, бейслайн p95=0ms 0 таймаутов
    • NARROW pytest subset (pytest -m smoke) — пропущено как опциональное, локальный mechanical green держался все 4 дня
  • Зависимость: sentry-sdk>=2.0.0 (актуально 2.59.0)

  • Deploy-runbook в 2026-05-08_{denis}{plan}_cp3_deploy_runbook.md (CP-2 + CP-3 шипались бандлом — runbook общий). Pre-flight checklist: backup observability.sqlite через .backup API, snapshot crontab -l, applied миграция через migrate_observability.py --apply (идемпотентна), установка retention-cron, smoke-проверки. Rollback path: backup-файлы лежат до 15 мая, crontab snapshot тоже — восстановление одной командой если что

исправление

Каскадные определения: дела перестают теряться после первого определения без СРО (T1)

375 уже-уведомлённых дел вернулись в мониторингтаблица `rulings` — новая схема (несколько определений на одно дело)новая суточная метрика на /ops/sources

Что было раньше

Алгоритм отбора (документ от 7 мая) описывает реалистичный сценарий:

  1. Несколько кредиторов подают на банкротство одного должника.
  2. Суд принимает к производству одно заявление, остальные оставляет «без движения» — даёт срок устранить недостатки.
  3. Если первое определение с целевой СРО — мы шлём Telegram. Хорошо.
  4. Если первое определение по «обездвиженному» заявлению или вступлению в дело без СРО — мы тоже шлём Telegram (дисциплина «нет СРО — пишем»). Дело уходит в статус notified.
  5. Через 7-30 дней заявитель №1 устраняет недостатки. Появляется второе определение по тому же делу — теперь с целевой СРО.
  6. Это второе определение мы пропускали. Система видела notified и больше к делу не возвращалась.

Корень: на таблице rulings стоял UNIQUE INDEX (case_id) — на одно дело могло быть только одно определение в базе. Второе определение технически не могло записаться.

Эмпирика на 7 мая: из 394 уведомлённых дел 22 кейса были notified без СРО — и 15 из них (≈68%) на самом деле имели позже выпущенное определение с целевой СРО, которое мы потеряли.

Что мы сделали

Перестроили слой хранения определений с нуля. Семь подзадач, все на проде.

1. Несколько определений на одно дело

Сняли UNIQUE на case_id. Идентичность теперь не «дело», а «документ суда» — каждое определение хранится по уникальному ключу из URL kad-арбитража. Цепочка определений по одному делу теперь честно живёт в базе как несколько строк, а не затирается.

2. Cascade-watching — дела notified без целевой СРО возвращаются в мониторинг

Главная страница мониторинга раньше смотрела только на дела со статусом watching. Теперь дополнительно подбирает дела:

  • статус notified
  • определение есть, но целевой СРО нет (ни в каноническом виде «ААУ ЦФОП АПК», ни в legacy-поле sro_name — проверка через регистро-нечувствительное сравнение поверх кириллицы)
  • дело не в архиве (архивация без СРО — issue #180, шипалось 6 мая)
  • от последнего обновления прошло меньше 60 дней
  • последняя проверка — больше часа назад (cascade-перепроверки в 4 раза реже чем основной тир, потому что новые определения по уже-уведомлённым делам — редкое событие)

Логика: пока на деле не появилась целевая СРО, мы продолжаем смотреть его раз в час следующие 60 дней. Появилось — отрабатываем как обычно. Не появилось за 60 дней — отпускаем (дело либо умерло, либо это уже не наш сегмент).

3. Атомарная запись определений

Раньше определение и связанные с ним СРО писались двумя отдельными SQL-вызовами. Если первый прошёл, а второй упал — определение лежало в базе без списка СРО, и логика «есть ли целевая СРО» падала на legacy-проверку по подстроке. Теперь обе таблицы пишутся в одной транзакции: либо обе, либо ни одной.

4. Защита от повторного Telegram

Когда то же определение (по тому же URL kad-арбитража) попадает в систему второй раз — что бывает на cascade-перепроверках — система видит «у этого дела уже есть целевое определение» и молча пишет в аудит без отправки Telegram. Никаких дублирующих уведомлений.

5. Корректные счётчики на дашборде

Подсчёт «дел с СРО», топ-5 СРО, и список дел в /buyers раньше считал строки определений, а не дела. Если у одного дела теперь может быть несколько определений с одной и той же СРО — топ-5 раздуется в два раза без переделки. Все три места переведены на «считать уникальные дела».

6. Суточная метрика «подозрение на переиздание»

Возможен ещё один сценарий: kad-арбитраж публикует то же самое логическое определение под новым document_uuid (исправительное определение, CDN-перепубликация). Это создаст новую строку в rulings, хотя по сути это та же запись — просто переоформленная.

Сейчас на проде таких случаев 0 из 5082 дел. Но если их станет больше 1% — значит надо запиливать ещё один уровень дедупликации (по тексту определения, не по URL). Поэтому каждую ночь в 03:50 UTC автоматически считается метрика и пишется в логи. Если перевалит порог — увидим в логах сразу.

Что ты увидишь

На текущий момент:

  • 375 дел уже сейчас вернулись в мониторинг через cascade-watching. Это те самые notified без целевой СРО, которые система раньше теряла. Каждое из них теперь будет перепроверяться раз в час в течение 60 дней с момента последнего обновления.
  • На странице Покупатели топ-5 СРО и счётчик «дел с СРО» теперь показывают честные числа в делах, а не в определениях. На текущий момент with_sro = 373 (а не «X с дублями»).
  • Если по одному из этих 375 дел появится определение с «ААУ ЦФОП АПК» в ближайшие 60 дней — придёт Telegram как обычно. Это и есть основной возврат от этого фикса.

Что дальше

  • 24-72 часа наблюдения. Cascade-watching добавил 375 дел в мониторинг — это в плюс к 209 делам в watching. Каждый cron-тик мониторинга теперь будет проверять чуть больше кейсов. Расход прокси-трафика должен вырасти максимум на 20-30% — за этим следим на странице Ops · Sources.
  • Появятся ли реальные «переиздания» — увидим через ту самую ночную метрику. Если строка в логах начнёт показывать ненулевое число и расти — запиливаем дедупликацию по тексту (это уже отдельный эпик, спека к нему лежит готовая).
  • Старые архивированные дела (18 кейсов из issue #180) cascade-watching не задевает — они в архиве и должны там остаться. Если потом захочешь их аудитнуть руками — /cases?archive_view=archived это отдельный фильтр, добавлен 6 мая.

Технические детали (для интересующихся)

Раскрыть
  • Эпик #222 (Bankruptcy Monitor — Project #5), 7 sub-issues #223-#229. Закрылся за один день: discovery + спека + 4 раунда Codex review + 7 коммитов на main. Все sub-issues прошли цикл Claude → Codex (low reasoning + split prompts) → revise/APPROVE → Gemini SHIP → коммит → push.

  • Спека под капотомpersonal/denis/2026-05-08_{denis}{spec}_t1_unique_index_migration_v2.md. Версия v2.1 после двух раундов Codex review (v1 получил REVISE с 5 Tier-1 блокерами, v2 — 4 концретных бага в split-prompt раунде). Главные архитектурные решения:

    • Идентичность определения = source_document_key (нормализованный URL kad-арбитража: lowercase scheme/netloc, percent-encoding round-trip, strip trailing slash). На проде все 396 строк имели уникальный непустой document_url, так что миграция прошла без коллизий.
    • pylower как SQLite custom function: встроенный lower() в SQLite ASCII-only — для русских СРО-имён надо было явно зарегистрировать обёртку над Python str.lower(). Иначе legacy-substring-match по sro_name молча проваливался на смешанном регистре.
    • changed_to_target как notify-gate: возвращается из атомарного upsert_ruling_with_sros и означает «это определение перевело дело из «нет целевой СРО» в «есть целевая СРО»». Логика «слать ли Telegram» теперь сводится к одной строчке.
    • logical_act_key отложен в v3 — гейтится метрикой re-issue (см. п. 6 выше). На текущем поведении функционально корректно: даже если будет 2 строки на одно логическое определение, Telegram отправится ровно один раз (потому что вторая запись увидит «у дела уже есть target» → silent).
  • Атомарная миграция в src/db/t1_migration.py — отдельный transaction wrapping ALTER + backfill + DROP/CREATE индексов. Pre-flight проверяет инварианты (uniqueness + non-null document_url) — если падает, миграция вообще не стартует. Post-check проверяет финальное состояние — если legacy-индекс не дропнулся, миграция откатывается. На проде применилась чисто, post-check passed.

  • Решение «cascade-watching ограничен status='notified', а не также rejected/returned/stale/skipped. Денисов алгоритм формально допускает каскад через rejected-цепочки, но эмпирически это редкий и шумный случай — лучше явное ограничение и потом расширить, если понадобится. Зафиксировано в спеке § 9 Q4.

  • Тесты: 97 новых T1-специфичных тестов (16 на schema migration, 26 на document identity helper, 14 на atomic upsert, 20 на cascade-watching monitor, 8 на dashboard SQL, 13 на E2E + monitoring metric). Полный набор: 3677 / 3677 pass. mypy --strict baseline-стабильный, ruff clean на всех изменённых файлах.

  • Per-task review chain поймал:

    • Codex r1 на v1 спеке — 5 Tier-1 блокеров (monitor skip pre-DB / pdf_path unsafe identity / non-atomic ruling+sros / silent migration error / inline SQL multiplies rows)
    • Codex r2 split-prompts на v2 — 4 концретных бага (case-sensitivity на пустом target_set, falsy bytes check, 4 migration safety bugs)
    • Codex r1 на T1-04 — APPROVE
    • Codex r1 на T1-05 — APPROVE с подтверждением semantic equivalence
    • Codex r1 на T1-06 — APPROVE
    • Codex r1 на T1-07 — APPROVE с одной NIT (>= vs > для строгого порога — пофиксили)
  • Hetzner deploy 8 мая: код уже прилетел на прод во время волны параллельных коммитов (другая сессия рестартанула web — миграция автозапустилась через init_db). Когда дошла очередь до ручного deploy-чек-листа — миграция уже была применена, оставалось только установить cron на ночную метрику + smoke-checks. Запасной DB-снапшот не требовался.

  • Бэйслайн метрики переиздания: count=0, case_total=5082, pct=0.0 на 8 мая. Cron активирован: 50 3 * * * UTC.

  • Deploy runbook с pre-flight + rollback options (revert + DB restore) — personal/denis/2026-05-08_{denis}{plan}_t1_deploy_runbook.md.

новая возможность

Админ-страницы: расписание, источники, детальный пробег (CP-3)

новые страницы /ops/jobs, /ops/sources, /ops/runs/{id}две новые кнопки в верхнем менюреестр из 24 источников данных и инфра-провайдеров

Что было раньше

Прошлый шаг (CP-1) дал страницу Ops · Runs — список запусков всех 32 фоновых задач. Видно, что когда стартовало, сколько шло, упало или нет.

Но оставались дыры:

  • «А когда они запустятся в следующий раз?» — расписания крона жили в crontab -l на сервере, не в UI. Чтобы посмотреть когда следующий пробег monitor, нужно было лезть по SSH.
  • «Сколько мы тратим на каждый источник?» — Evomi-прокси, AstroProxy, 2captcha, OpenRouter, ЕГРЮЛ, Контур — расходы рассыпаны по разным таблицам. Не было единого ответа на вопрос «что у нас вообще есть и что почём».
  • «Что именно сделал этот конкретный запуск?» — клик по строке в Ops · Runs ничего не давал. Только имя + длительность + статус.

Что мы сделали

Три новые страницы плюс реестр источников под капотом.

Ops · Jobs — расписание всех 32 задач

Читает cron_manifest.yaml (декларативный реестр всех cron-задач, заведённый в CP-1) и для каждой строки показывает:

  • имя + русское описание
  • расписание (cron-выражение */15 * * * * и т.п.)
  • когда следующий запуск в UTC — посчитано через библиотеку croniter
  • источники данных — какие именно сайты/API задача дёргает (например monitorkad.arbitr)
  • тип лока (single-instance / parallel)
  • путь к bash-обёртке для отладки

Если cron-выражение битое или cron_manifest.yaml не парсится — страница не падает 500-кой, а показывает плашку с ошибкой и рендерит остальные строки.

Ops · Sources — реестр источников и расходы

Самая «жирная» страница. Группирует 24 источника по категориям:

  • Источники данных (17) — kad.arbitr, casebook.ru, fedresurs.ru/backend, pb.nalog.gov.ru, ЕГРЮЛ, ФНП, Контур.Фокус и т.д.
  • Инфра-провайдеры (7) — Evomi (residential proxy), AstroProxy, Hetzner, Selectel, 2captcha, OpenRouter (LLM fallback), Sentry

По каждой строке: тип (бесплатный скрейп / платный API / гибрид), статус (активный / устарел / отключён), коммит-бюджет в месяц, фактический расход с 1-го числа, дата последнего обновления записи.

Особый случай — три прокси-провайдера: их трафик пишется в общую таблицу без атрибуции по провайдеру (так исторически устроено), поэтому сверху рендерится строка «Unallocated proxy MTD: $X», а в каждой из трёх отдельных строк — заглушка (included above). Это не баг, это честное отображение данных.

2captcha считается отдельно — там MTD это сумма положительных дельт баланса (фильтрует пополнения, оставляет только реальный сжог). Если снапшотов меньше двух — показывается «balance only — burn calc requires 2 snapshots» вместо мусора.

Если cases.sqlite залочена долгим cron-ом — таймаут 5 секунд, потом ячейки MTD рендерятся как «Cross-DB read unavailable». Страница всё равно открывается.

Ops · Runs/{id} — детальная страница по одному запуску

Клик по любой строке в Ops · Runs теперь открывает детальную страницу. Рендерит все метаданные конкретного пробега: имя, статус, время старта/финиша, сколько шёл, сколько ждал блокировку основной БД, какой git-SHA крутился, путь к лог-файлу, код выхода.

Если CP-2 (следующая фаза, расширение телеметрии — детализация по обработанным записям) ещё не докатилась — страница показывает амбер-плашку «Per-event drill-down requires CP-2 ship». Когда CP-2 приедет, на той же странице сверху появится гистограмма по типам событий, снизу — пагинированный raw-таймлайн.

Реестр источников под капотом

Заведена новая таблица source_registry в data/observability.sqlite. 24 строки засеяны: имя, отображаемое имя, тип, категория, кост-модель (JSON), путь к Python-модулю-владельцу, статус. Имена нормализованы — twocaptcha (а не 2captcha), kontur_basic (а не kontur_focus), proxy_seller помечен deprecated (мигрировали на Evomi 30 апреля).

Cross-validation между реестром и cron_manifest.yaml: если cron-задача упоминает source_keys: ["kad.arbitr"], но в реестре такого имени нет — линтер ругается. Это защита от очепяток.

Что ты увидишь

В верхнем меню дашборда теперь три кнопки на префиксе Ops:

  • Ops · Runs (с CP-1) — список запусков
  • Ops · Jobs (новое) — расписание
  • Ops · Sources (новое) — источники и расходы

И клик по любой строке в Ops · Runs теперь ведёт на детальную страницу /ops/runs/{id} — раньше это была пустая ссылка.

На текущий момент Ops · Sources уже показывает живые цифры:

  • Evomi-прокси — $49.99/мес коммит, фактический расход с 1 мая в строке Unallocated proxy MTD
  • AstroProxy — $20/мес коммит
  • 2captcha — текущий баланс + сжог за месяц через дельты
  • OpenRouter (LLM fallback) — фактический MTD из таблицы llm_extractions WHERE accepted=1
  • Hetzner — $8/мес
  • Selectel (RU-фасад) — $9/мес

И 17 строк источников данных — kad.arbitr, casebook, fedresurs, pb.nalog и остальные — у них $0/мес коммит (бесплатный скрейп), но видна дата последнего обновления записи и владелец модуля для отладки.

Что дальше

  • 24-часовой watch до 9 мая ~07:00 МСК — следим чтобы /ops/sources не таймаутил на cases.sqlite под нагрузкой (там сейчас ~50 МБ и параллельные мониторинговые писатели)
  • CP-2 (следующая фаза) уже частично замёрджена — пишет события на каждый шаг внутри запуска. Когда докатится полностью, страница /ops/runs/{id} оживёт: гистограмма событий + raw-таймлайн вместо текущей амбер-плашки
  • Бэкапы /var/backups/bankruptcy-monitor/{observability,cases}_cp3_pre_*.sqlite лежат на Hetzner до 15 мая на случай отката, потом удалю вручную

Технические детали (для интересующихся)

Раскрыть
  • 9 sub-issues в Project #6 (Bankruptcy Monitor — Control Plane): #207 (schema) → #208 (Pydantic + repo) → #209 (manifest source_keys) → #210 (seed script + governance) → #211 (drill-down) → #212 (jobs page + croniter) → #213 (sources + cross-DB MTD) → #214 (top-nav links) → #215 (deploy runbook + p95 smoke)

  • Эпик #186 закрыт. ~21 коммит на main за 2 дня.

  • ~98 новых тестов + 4 smoke-бенчмарка (@pytest.mark.smoke — исключены из дефолтного pytest, прогоняются явно через pytest -m smoke)

  • Pre-deploy server-side p95 (после рестарта run_web.py на Hetzner, 50 итераций per route с прогревом):

    • /ops/runs — median 35ms, p95 40ms
    • /ops/jobs — median 36ms, p95 43ms
    • /ops/sources — median 4ms, p95 6ms (cross-DB read через cases.sqlite?mode=ro с 5s timeout — на холодной маленькой cases.sqlite рендер быстрый)
    • /ops/runs/{id} — median 33ms, p95 40ms
    • Все ниже 200ms — порога из AC
  • Архитектурные решения:

    • source_registry в observability.sqlite, не в cases.sqlite — чтобы реестр не зависел от write-contention на основной БД
    • TypedDict + union для MtdSpend: float | TwocaptchaMtd | None — каждое значение ячейки явно типизировано (mypy --strict clean)
    • Cross-DB read pattern: sqlite3.connect("file:cases.sqlite?mode=ro", uri=True, timeout=5.0) — read-only URI с 5-секундным таймаутом; если БД залочена дольше — graceful fallback на placeholder
    • Multi-pass template injection prevention: handler /ops/runs/{id} использует single-pass regex substitution вместо template.replace() × N — последовательные replace могут переписывать ранее отрисованные фрагменты HTML, если те случайно содержат литерал плейсхолдера. Codex r2 поймал это до мерджа
    • /ops/jobs не кэширует croniter-вычисления — на 32 задачах меряет ~5ms total, но если manifest перевалит за 100 — добавим LRU-кэш на 5 минут
    • Decision 8 fallback: страницы готовы к до-CP-2 состоянию — если таблица pipeline_events отсутствует, рендерится amber-плашка вместо 500
  • Новая зависимость: croniter>=1.4.0 (для вычисления next-fire времени)

  • Per-task review chain: каждый sub-issue прошёл через цикл Claude → Codex (medium reasoning) → revise/APPROVE → Gemini SHIP → коммит. Codex раз поймал по 3-5 реальных багов на тикет (multi-pass template injection, twocaptcha pre-month anchor SQL allowing duplicate ties, dashboard nav grid overflow, CSS class vs data-attribute mismatch, и т.д.) — за пять волн ноль регрессий долетело до прода

  • Deploy-runbook с rollback-процедурой лежит в 2026-05-08_{denis}{plan}_cp3_deploy_runbook.md — 19 шагов, протестирован на этом самом деплое

новая возможность

Запущена обсервабилити пайплайнов (CP-1)

все 32 cron-задачиновая страница /ops/runs

Что было раньше

У нас 32 фоновых процесса, которые крутятся по расписанию: одни каждые 15 минут (мониторинг kad.arbitr), другие раз в день (поиск новых дел на casebook.ru, парсинг определений), третьи раз в неделю (обновление "Прозрачного бизнеса"), четвёртые раз в месяц (ЕГРЮЛ, ФНП).

Если что-то ломалось — узнавали либо из Telegram-алерта (если алерт настроен), либо случайно при просмотре логов в tail -f data/logs/*.log. Не было общего ответа на вопрос «всё ли запустилось сегодня?» — приходилось проверять каждый лог-файл отдельно.

Что мы сделали

Каждый раз, когда запускается любая из 32 задач, теперь записывается отдельная строка в журнал запусков:

  • имя пайплайна (например monitor, discovery, p5_bl_biddings)
  • когда запустился (с миллисекундной точностью)
  • сколько шёл (длительность)
  • статус: ok (отработал чисто) / failed (упал с ошибкой) / running (ещё идёт)
  • код выхода (0 = успех, 1 = ошибка, 130 = прерван по Ctrl+C, 143 = убит сигналом TERM)
  • git SHA — какая версия кода крутилась
  • путь к лог-файлу для детальной отладки

Журнал хранится в отдельном файле базы (data/observability.sqlite), чтобы не мешать основной бизнес-БД (data/cases.sqlite). Это важно: даже если журнал сломается, сами задачи продолжат работать как обычно — телеметрия никогда не блокирует прод.

При сбое задачи (исключение в коде, kill -9, Ctrl+C) трап-обработчик в bash успевает записать failed + код ошибки прежде чем процесс завершится. Если же существующая задача уже имела свой обработчик выхода (например, cron_monitor.sh снимает lockfile при выходе) — наш обработчик корректно вызывает её первой и только потом пишет в журнал.

Что ты увидишь

Новая кнопка Ops · Runs в верхнем меню дашборда (https://denis.ownmail.dev) → откроет страницу со списком всех запусков:

  • На каждой строке: что за пайплайн (русское описание простым языком), когда запустился (московское время), сколько шёл, успешно или нет (цветной индикатор)
  • Фильтры: только running / только failed / по имени / лимит количества
  • Сортировка: новые сверху или старые сверху
  • Под капотом — это реалтайм из новой таблицы pipeline_runs

Например, на текущий момент уже видно:
- monitor → "kad.arbitr.ru мониторинг каждые 15 минут — новые определения по watching делам", запустился 03:52 МСК, шёл 1 мин 29 сек, статус ok
- archive_old_no_sro → "Архивация notified-без-СРО старше 14 дней", запустился 03:39, шёл 1 сек, ok (заархивировал 1 кейс)

Что дальше

  • 24-часовой watch-период до 8 мая ~04:00 МСК — все 32 задачи должны зарегистрироваться хотя бы раз
  • После — финальная проверка: не выросло ли время блокировок основной БД из-за новой телеметрии. Pre-deploy замер показал p95 = 1 мс, 0 таймаутов на 950 тыс попытках — должно остаться так же
  • В планах CP-2 (расширение телеметрии — детализация по обработанным записям внутри каждого запуска) и CP-3 (детальная страница по конкретному запуску — drill-down + replay button)

Технические детали (для интересующихся)

Раскрыть
  • 10 коммитов на main: daa7535..6c58b94
  • 8 sub-issues в Project #6 (Bankruptcy Monitor — Control Plane): #191..#198
  • Ключевые файлы:
    • scripts/_run_step.sh — bash-хелпер, который sourcing-ом подключается каждым cron-обёрткой
    • src/observability/cli.py — Python CLI для записи start/finish строк
    • src/observability/run_step.py — context manager для прямого использования из Python
    • src/web/ops/ — модуль с handler'ом /ops/runs
    • scripts/cron_manifest.yaml — декларативный реестр всех 32 задач (имя, расписание, описание, лог)
  • 84 новых теста (3075 → 3159), включая интеграционные bash-тесты сигналов SIGINT/SIGTERM, fail-open пути, idempotent double-source и трап-цепочку для существующих lockfile-обработчиков
  • Pre-deploy baseline на проде: p95 lock wait 1 мс, max 78 мс, 0 таймаутов на 950к попыток (10 параллельных писателей × 120 сек)
  • Архитектурные инварианты:
    • observability.sqlite — отдельный файл от cases.sqlite (mitigates write contention)
    • Fail-open semantics: телеметрия не блокирует прод (timeout 5s, swallow errors)
    • Idempotent: двойной source = no-op
    • Trap chaining: предыдущий EXIT trap (lockfile cleanup) сохраняется