По ревью PR #3582. Три мутации тесты не ловили (5 passed): нормализация
только пробелов, односторонний LIKE, снятый EXISTS #968. Плюс латентный
дефект: UNIQUE(source, source_id) не запрещает complex иметь два
objective-проекта, DISTINCT ON брал любой, сверка его отвергала, и верный
проект терялся (воспроизведено: complex с «Клён» и «Сосны», брался «Клён»).
- nearest_cx в обоих SQL: нормализованные ключи в LATERAL, сверка name_ok
считается там же и стоит в ORDER BY после расстояния (name_ok DESC,
source_id) — при двух проектах берётся сверенный, выбор детерминирован.
Фильтр по-прежнему ПОСЛЕ DISTINCT ON.
- тесты: «Квартал "Татлин"» = «Квартал Татлин» (кавычки посреди имени),
«Парковый» ⊂ «Парковый квартал» (обратная сторона LIKE), ближайший
complex без лотов не съедает матч (#968), complex с двумя проектами.
Старый тест пунктуации проверял пробел — переименован честно.
Прод 17.09 (только чтение): выбор nearest_cx у всех 185 gap-fill объектов
в обоих SQL совпал с головой PR (0 расхождений, 176 принято, 9 отвергнуто);
время на центре ЕКБ 1 км: конкуренты 70 → 69 мс, цена 103 → 103 мс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
На голове b74b02ea CI / backend-tests был красным (run 11737): 5125 passed, но
все 5 тестов test_2962_competitors_gapfill_bridge.py пропустились с причиной
«нет расширения postgis», а в skip_allowlist.txt их не было — гейт вернул rc=1.
В CI ПТИЦЫ plain postgres:16, а SQL конкурентов без PostGIS не исполнить
(ST_DWithin по geography).
- ci.yml: образ postgis/postgis:16-3.4 (тот же, что в ci-tradein.yml); образ
сам создаёт расширение в POSTGRES_DB.
- тест: при CI/GITHUB_ACTIONS skipif выключен — если PostGIS из CI пропадёт,
тест упадёт с настоящей причиной, а не пропустится молча.
- skip_allowlist.txt: пропуск объявлен только для машины без базы.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
В шести тестах test_cadastre_bulk.py комментарий к моку с xmin=None
говорил, что пропускаются «grid-walk + territorial_zones фазы». Фазы
territorial_zones в harvest_quarter после предыдущего коммита нет;
при bbox=None пропускается только grid-walk (bulk_harvest.py:176, :335).
Правка только в комментариях, поведение тестов не меняется.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Что было. Для конкурентов вне objective_complex_mapping мост шёл
«ближайший complex → objective_lots.complex_id → все project_name».
complex_id проставлен один раз миграцией 76 на загрузке 10.05, а
еженедельный 70_parse_objective_raw.py UPSERT'ом по objective_lot_id
переписывает project_name и не трогает complex_id. Прод 17.09: из
303 677 строк с complex_id у 236 354 проект чужой (все вставлены 10.05,
переписаны 17.05–15.09). У 185 gap-fill конкурентов своих лотов 23 %,
скорость в медиане завышена в 39 раз (сумма 65 798 против 1 609 сделок/мес).
Что сделано. В _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL complex
связывается с проектом через complex_sources (source='objective', 1:1),
лоты и сделки берутся по project_name. Связь в complex_sources почти вся
fuzzy и не проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» —
'Гранит'), поэтому имя проекта дополнительно сверяется с именем объекта
ДОМ.РФ без регистра и пунктуации; сверка стоит после DISTINCT ON, внутри
join планировщик гонял regexp по 383k пар (3 с).
Замер на проде (все 1556 объектов, окно 3 мес): явный маппинг 308 и
остальные 1063 — изменилось 0; gap-fill 185 — изменились все, у 9 связь
отвергнута (7 чужих проектов + 2 «Традиции»/«Традиция»), доля своих лотов
23 % → 96 %. На 500 участках: 4317 прежних пар участок-конкурент — 0
изменений, 598 gap-fill пар — изменились все. Время запроса конкурентов
393 → 65 мс, ценового fallback 23 → 54 мс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
harvest_quarter после основного обхода делал отдельный grid-walk слоя 875838
(49 запросов к НСПД на квартал) и писал результат в cad_territorial_zones.
Эту таблицу никто не читает: на проде 1 строка за два полных прохода по ЕКБ,
zone_code/zone_name пустые (маппинг свойств не совпадает с ответом НСПД).
ПЗЗ до отчёта доходят другой трубой: nspd_sync -> nspd_quarter_dumps.features_json.
Владелец 17.09 выбрал удаление фазы.
Удалено: блок Phase 2.5 в harvest_quarter, писатель _save_territorial_zones,
обёртка NSPDBulkClient.get_territorial_zones_in_bbox (других вызовов нет),
тесты писателя и мок в test_cadastre_bulk. Таблица и её данные не тронуты,
миграций нет. Поправлены комментарии, ссылавшиеся на фазу.
Новый тест: квартал без overflow с валидным bbox стоит ровно один запрос
к НСПД (search_by_quarter), фазы прогресса без territorial_zones_started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Семантический конфликт двух PR, смерженных 17.09 подряд. Текстового конфликта не
было, CI каждого PR был зелёным на своей голове, а объединённое дерево падало:
test_3466_corridor_tier_a.py (2), test_estimator_radius_floor.py (2)
AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'
TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor'
#3554 (Tier A и advisory_only) писал тесты против settings.estimate_corridor_clamp_*,
а #3556 (#2380) убрал эти поля из Settings в константы CORRIDOR_CLAMP_SLACK
(estimator) и CORRIDOR_CLAMP_MIN_N (app.core.config) и снял параметр
radius_floor_factor у хелпера. Продуктовый код не затронут: в app/ старые имена
остались только в комментарии, на проде AttributeError не было. Но красный test
блокировал деплой МЕРЫ: на проде до сих пор образ до второй пачки мержей.
Тесты переведены на константы, значения прежние (0.40, 10, 0.8).
pytest tests/ — 6374 passed, 44 skipped, rc=0; ruff check/format — rc=0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Было: est_days_on_market — одно число, медиана days_on_market активных
аналогов (возраст висящего объявления, цензурированная выборка), в
trade_in_estimates не сохранялось, GET по ссылке отдавал null.
Стало:
- эстиматор берёт exposure_days из house_placement_history: те же комнаты,
площадь ±15%, дома в радиусе подбора аналогов, снятые за 24 мес.;
квартили как percentile_cont; при выборке < 30 окна нет;
- миграция 324: est_days_p25/p50/p75/n в trade_in_estimates, пишутся в
INSERT и при ревайвле, поднимаются на GET (/estimate/{id} и /r/{token});
- API: exposure_window {p25_days, p50_days, p75_days, n}; старое поле
est_days_on_market оставлено и равно p50 окна, у старых строк null;
- B2B hero: «До снятия объявления N–M дн.» вместо «Срок продажи»;
- /docs: убран «прогноз срока» из описания полного отчёта.
Порог 30 — замер 17.09 на 51 прод-когорте: ошибка края окна при 20 лотах
24%, при 30 — 18%. Окно есть у 94 из 217 недавних оценок.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/house-analytics при <8 архивных лотах добирает до 30 соседних домов в
радиусе 300 м и возвращает radius_m. Карточка истории цен всё равно писала
«История цен в этом доме · только этот дом», а рядом на том же экране стояло
«в радиусе 300 м».
Заголовок и подпись теперь зависят от radius_m: при расширении — «дом и
соседние в радиусе N м». Поправлены обе версии экрана (v2 и старый
PriceHistoryChart).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Фильтры матча стояли только на стороне ГАР, UPDATE houses шёл по канону
улица+дом по всем домам всех регионов. После #3523 (матч для 77 и 50) это
стало порчей: прод 2026-09-17 — 578 домов региона 66 с guid региона 50,
340 — с guid 77, 1371 дом 50 с guid 77, 497 домов 77 с guid 50. По чужому
guid ЖКХ/ФРТ/капремонт-загрузчики тянут данные другого дома.
Матч сверяет регион дома с регионом ГАР-строки и ранжирует канон внутри
региона. Перед матчем снимаются уже проставленные canon_addr-guid чужого
региона (дом без региона не трогаем) — иначе дом без пары в своём регионе
так и остался бы с чужим. Эффект на проде — после ops-прогона --match-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Резолвер города знал 24 имени региона 66, а развёртки идут ещё по 23 городам
(Реж, Карпинск, Кушва, Лесной, Тавда…). Город не распознан — коридор ДКП
собирался по одной улице во всей области, где «Ленина» в основном
екатеринбургская, и Реж получал цену Екатеринбурга.
Резолвер теперь знает и города развёрток (кроме городов других регионов).
Общий словарь региона не трогали: на нём гейты геокодера и токены ключа дома.
Замер прода: по всем 23 городам сделки лежат под теми же е-формами, в сделках
ЕКБ ни одного адреса с этими именами отдельным словом.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
С 13.09 21:14 по 16.09 18:52 пять объявлений (10776456, 10775964, 10775945,
10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой
SPA-оболочкой ~381 КБ без блока контактов. Счётчика попыток на объявлении не было,
они возвращались в голову каждого снапшота и пятью недогрузами подряд выбивали
брейкер: 16 прогонов закончились ровно attempted=5 incomplete=5.
Миграция 321: listings.detail_incomplete_count / detail_incomplete_at.
yandex_detail_backfill пишет недогруз на объявление; снапшот не берёт карточку
сутки и не берёт совсем после трёх недогрузов (счётчик incomplete_given_up);
повторный недогруз уже известной карточки не двигает брейкер, первый — двигает,
защита от системного недогруза сохранена.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Конфликт только в tests/skip_allowlist.txt: обе стороны дописали блок в конец —
live-тесты пула прокси (#3299/#3310/#3404) и миграции 310 (#3385). Оставлены оба.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Конфликт только в tradein-mvp/backend/tests/skip_allowlist.txt: обе стороны
дописали блок в одно место (#3252 — тесты апсерта карточки ДомКлика, #3385 —
миграция 310). Оставлены оба блока. Номер миграции ветки 320 не пересекается
с main (максимум там 310).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI / changes краснел гейтом #2752: «блокирующий DDL без lock_timeout (REINDEX INDEX
gar_house_flats_canon_idx)». Добавлен SET LOCAL lock_timeout = '5s' сразу под BEGIN.
REINDEX оценён замером на проде 17.09 (только чтение):
- gar_house_flats 3,27 млн строк / 942 МБ heap (1,5 ГБ с индексами), индекс частичный
(flat_count > 0), 201 513 строк, 8,8 МБ;
- эквивалент перестройки (seq scan + tradein_canon_addr в один поток) 5,6 с — столько
таблица закрыта для записи и для планировщика любого запроса к ней; читают её только
ручной ГАР-загрузчик и ре-матч, пользовательский путь не задет;
- шаг S0b меняет ключ у 0 из 201 513 индексируемых строк: сегодня REINDEX ничего не
меняет, но оставлен в той же транзакции, что и тело функции, чтобы строки, загруженные
до деплоя, не остались со старым ключом. Прежнее обоснование («следующая загрузка ГАР»)
было неверным: новые строки и так получают ключ нового тела.
REINDEX CONCURRENTLY возможен (раннер гонит файл psql'ем в autocommit, образец 270), но
не взят: ждёт все транзакции базы старше своего снимка (сборщики держат idle in
transaction минутами), lock_timeout не терпит, оборванный оставляет невалидный *_ccnew,
тело функции коммитится раньше индекса.
Тест по значению на живом Postgres: 311 гоняется как в раннере, пока «загрузчик» держит
ROW EXCLUSIVE на gar_house_flats — миграция падает LockNotAvailable быстрее 10 с, тело
функции откатывается (xmin строки pg_proc не меняется). Без SET LOCAL тест красный:
DID NOT RAISE — миграция дождалась загрузчика.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deals.house_type пуст у всех 434 911 сделок Росреестра (прод 17.09), поэтому
в прогоне с --resolve-house-id штраф за несовпадение типа дома в отборе
аналогов не срабатывал ни разу. Боевой estimate_quality с #3234 подставляет
тип из houses (по резолвленному house_id, иначе ближайший дом в 60 м), когда
его нет в форме; бэктест этого не повторял.
Теперь при --resolve-house-id пустой тип сделки дозаполняется тем же
_lookup_house_facts, свой тип сделки главнее. Счётчик house_type_from_houses
в блоке house_id_resolution показывает, скольким сделкам тип подставлен.
Год и этажность из houses намеренно не берутся: они сдвинули бы метрики по
другим признакам и смешали бы замер эффекта типа дома. Без флага вывод прежний.
Основная часть issue (код материала стен Росреестра -> тип дома) не сделана:
нужен справочник 126-УНСИ и решение владельца по соответствию.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
best_layouts.py:1113 (эпик #2464). Поле objects_total_in_radius заполнялось
тремя ветками по-разному: ранний пустой ответ («фильтр никого не оставил»)
отдавал число комплексов ДО exclude/filter, ветка «нет velocity» и штатная —
ПОСЛЕ.
Смысл выбран по потребителям, а не по имени поля: BestLayoutsBlock.tsx и
layout_tz_pdf.py печатают его только как N в «покрытие P% (Y из N
комплексов)», а P в штатной ветке считается по отфильтрованным. Число до
фильтра сделало бы строку внутренне противоречивой («100% (1 из 2)»).
Поэтому ранний пустой ответ приведён к len(complex_groups); число obj_id до
фильтра по-прежнему отдаётся в raw_objects_total. Штатная ветка не менялась.
Тест проверяет поле во всех трёх ветках при filter_competitor_obj_ids;
два старых теста, закреплявших число до фильтра в пустом ответе, обновлены.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>