Отчёт печатал измерения, которых не делал:
1. Выдуманный «срок продажи 4–118 дней». _days_on_market_range при
отсутствии реальных days_on_market (~48% оценок) возвращал хардкод
(4, 118), а обложка и страница объявлений рисовали его как
измеренный срок экспозиции. Теперь функция возвращает None, и оба
call-site'а не рисуют срок (show_days=False) — как уже делает
страница сделок.
2. № отчёта хардкодил «EKБ» для любой оценки — объект в Серове /
Нижнем Тагиле получал екатеринбургский код на обложке, в шапках и
футере. Префикс теперь выводится из target_address (ЕКБ / НТ / СЕР /
…); неизвестный город → нейтральный «МЕРА», а не ложный «EKБ».
3. Статичный «сделки на 10–18% ниже» противоречил рассчитанному в том
же PDF «−N%». Совет на обложке и баннер на странице сделок теперь
ссылаются на реальный _discount_pct (тот же, что chip «−N%»), с
нейтральной формулировкой когда дисконт неизвестен. Удалён ложный
хвост «(Екатеринбург, 2026)».
Тесты: +11 кейсов (suppression (4,118), city-aware № отчёта,
computed-discount вместо «10–18%»). Все 23 проходят, ruff чисто.
H1 (деньги, каждая оценка): time-adjust ДКП-коридора брал СберИндекс `residential_real_estate_prices`,
а для обл.66 это 100% «Первичный рынок» (новостройки) → коррекция направленно противоположна
вторичке (первичка Jan→May +0.89% vs вторичка −0.39%). Fix: SBER_COEFF_DASHBOARDS →
(real_estate_deals, dinamika-tsen-obyavlenii) — обе вторичка; + segment-guard в SQL
(`segment ILIKE '%вторичн%'`) как defense-in-depth. Live-verified: остаются только Вторичный-серии.
H2 (честность): `_compute_confidence` ветка «≥4 адреса» не имела потолка разброса → пул с ±45%
IQR + «расширили радиус из-за нехватки данных» уходил как «medium» вопреки объяснению. Fix:
medium требует IQR/median < 0.35; + force-low при fallback-расширении с разбросом > 0.30.
Regression-гейт: перегенерён baseline (dedup OFF, как в гейте) — ровно 1 из 277 фикстур-кейсов
medium→low (высокодисперсный, справедливо): calibration.medium.n 2→1 (mape 14.64→6.99 —
ненадёжный ушёл), low.coverage 81.82→81.88. Δ минимальна и обоснована. 127 тестов зелёные.
Stale-СберИндекс (72д) — НЕ трогаю в коде: time-adjust валидно ре-базит Jan→May; сброс потерял
бы валидную коррекцию. Реальный gap операционный (pull не гонялся с 19.06) — в scheduler/ops.
Валидация вскрыла: не-ЕКБ headline over-priced — analog Tier-S (same-building) fallback БЕЗ geo-предиката матчил одноимённую улицу+дом в ДРУГОМ городе → тянул ЕКБ-листинги (40191 EKB/~0 non-EKB) с distance_m=0. Серов ask-head 186k vs sold 30k; Н.Тагил 187k vs deal-corridor 89k.
Part 1 (Tier-S geo-bound, ~4624): + ST_DWithin(geom, subject, radius) в address-prefix fallback (зеркалит Tier-W). >99.97% листингов с coords → режет только cross-city false-positives.
Part 2 (deals-headline-fallback): _fetch_dkp_corridor widen на city-wide при street-sample <3 (gated city!=екатеринбург); _price_from_inputs: при median_ppm2<=0 (нет листингов) + anchor_tier None + dkp_raw>=3 → headline из deal-corridor, confidence=low, n_analogs=0, expected_sold=None.
Deep-review ✅ APPROVE (no 🔴/🟠; ~10 downstream stages traced coherent). EKB byte-green regression-гейт (fallback требует median_ppm2<=0, невозможно для frozen fixture; Part 1 SQL не в replay). Тесты: Н.Тагил 0-листингов+corridor85911 → headline=85911 (не 0/186k); EKB-control dense → headline из листингов (fallback не срабатывает). Full suite 2418 passed.
Follow-ups (🟡): fallback без city-gate (sparse EKB n/a→deals, safe); api_analog_tier мислейбл 'city'; Part1/2a integration-test; EKB backtest post-deploy (rule #7).
Миграция 177 залила +47183 ДКП-сделки по всей Свердловской обл. (66, 368 городов,
deals.city заполнен). Потребители deals матчат по имени улицы БЕЗ city-фильтра →
одноимённые улицы дешёвых городов области контаминируют ЕКБ-числа.
C1 — street-deals + ДКП-коридор (клиент видит СЕЙЧАС):
- estimator._fetch_dkp_corridor + api/v1/trade_in.get_street_deals: добавлен city-скоуп
`city IS NOT NULL AND :address ILIKE '%'||city||'%'` (канонический deals.city как
подстрока целевого адреса — self-normalizing, адрес всегда содержит город).
- Live-repro (Ленина, 2к, 12мес): median 52 908 → 118 052 ₽/м² (было занижение ~−55%).
C2 — дневной пересчёт asking→sold ratio (imminent, «выкупная» −29%):
- tasks/asking_to_sold_ratio: deal_side + deal_global скоупятся на ЕКБ (asking-сторона =
listings покрыты скрейпом только по ЕКБ; в listings нет колонки city). Константа
_ASKING_CITY_PATTERN. ask-стороны не трогаем.
- Live-repro (rooms=2): sold_median all-city 82 593 (ratio 0.622) → EKB 116 858
(ratio 0.880). Предотвращает обвал «выкупной» на след. пересчёте.
- Когда появятся oblast-листинги (B1/B2) → заменить на per-city ratio через `district`.
Тесты: subset-тест 080↔refresh (mirror _drop_segment_guard прецедента для city-guard) +
позитивный тест скоупа; 125 passed вкл. regression-гейт (байт-зелёный — коридор заморожен
в фикстуре), expected_sold, idor, 781. Regression-гейт не затронут (dkp_raw — frozen kwarg).
Геокодер был жёстко EKB-bound и ВЫБРАСЫВАЛ корректные non-EKB геокоды (bbox-accept-фильтры) → блокировал оценку любого адреса области. Обобщено до region-66.
- OBLAST66_BBOX + is_within_oblast66_bbox (superset EKB-tight, EKB не затронут); EKB Yandex Tier1/2 fast-path сохранён.
- Все accept-фильтры (Yandex/Nominatim forward+suggest, geocode() accept+typo) → oblast-66.
- City-prefix по word-boundary _has_oblast_marker (не substring): EKB-улицы 'Серова 27'/'Ирбитская 5' и др. больше не роняют 'Екатеринбург,'-префикс (был silent wrong-city баг).
- Two-pass tie-break в accept-циклах: tight-EKB кандидат приоритетнее → EKB-результат идентичен прежнему.
- Region cross-check (Nominatim address.state / Yandex AdministrativeAreaName) отсекает соседние области (Тюмень и др.); bbox-fallback когда region отсутствует.
- DaData suggest: region-параметр (hard-filter), убран no-op restrict_value.
- +26 тестов: OBLAST66 superset TIGHT, far-town accept, Тюмень reject, marker +/- (street-collision), DaData region body.
Foundation для A2 (геокодинг non-EKB сделок) и live-оценки адресов области.
- scheduler.py import_rosreestr_dkp: снят фильтр city ILIKE '%катеринбург%', address из реального city источника, deals.region_code + новая deals.city заполняются (было: хардкод 'Екатеринбург,' + region_code NULL)
- migration 177: deals.city + индекс + бэкфилл существующих EKB-строк region_code=66/city
- guard city IS NOT NULL → address не NULL для ~15 null-city строк источника
- sync deploy/import-rosreestr.sh (ops-fallback) под тот же oblast-scope
- split dedup-теста: 077 (историческая) хранит EKB-фильтр, живой импорт — нет
Открывает +47183 не-ЕКБ сделок region 66, уже сидящих в источнике, ранее резавшихся на импорте.
Follow-up к смене user2 pilot→expired в roles.yaml. test_get_role_known_users
гонял user1..user10 == pilot циклом; user2 теперь expired. На PR #2472 этот
тест был skipped (paths-filter не матчил tradein-mvp/**, менялся только
auth/roles.yaml) → упал уже на полном деплое. Правка в обоих зеркалах
(tradein + main backend).
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5)
убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main.
Заодно:
- удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/),
импортировавших уже-удалённые или удаляемые сейчас legacy-модули
- тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET
тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.*
эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete
golden-parity тестов, потерявших legacy-оракл
- kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config —
ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(),
MagicMock HouseMatcher, region_code=66)
Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка
#2208 (test_search_cache_hit, не связан со scrapers).
Deep-review нашёл: удалённый test_kit_registry_completeness.py заменялся
test_routing_coverage_sets_match, но тот строил registry ИЗ хардкоженного
_PRODUCT_SOURCES через recording-stub и проверял тот же набор — тавтология,
не гоняющая реальный build_product_handlers(). Плюс _PRODUCT_SOURCES был
устаревшим: не хватало cian_history_backfill, deals_freshness_monitor,
osm_poi_ekb_refresh (все три есть в реальном build_product_handlers).
Фикс:
- _PRODUCT_SOURCES дополнен тремя пропущенными source'ами, сверен построчным
grep '"...": Handler(' по product_handlers.py (20 exact + 1 wildcard).
Список остаётся canonical hardcode (источник правды — сам product_handlers.py
+ scrape_schedules seed-миграции), с явным комментарием "поддерживается вручную".
- test_routing_coverage_sets_match удалён, заменён
test_real_build_product_handlers_covers_all_scheduled_sources: зовёт РЕАЛЬНЫЙ
product_handlers.build_product_handlers(ctx=None) + build_registry (не stub),
ассертит resolve_handler для каждого source из _PRODUCT_SOURCES ∪
_KIT_NATIVE_SOURCES. Уроненная handler-entry или scheduled source без
handler'а теперь роняет CI.
Регресс-пруф (сделан вручную, откачен): временно закомментировал
"osm_poi_ekb_refresh" в build_product_handlers → новый тест упал с
"misses source=osm_poi_ekb_refresh"; вернул строку → тест снова зелёный
(git diff на product_handlers.py пуст после отката).
Verify: full pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208); ruff 0.7.4 чист на изменённых файлах.
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend
(uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m
app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь
(_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers)
самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop
или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler
диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/
app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill
из scheduler.py) — мимо удаляемой legacy-машинерии.
app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop,
get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/
_drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт
app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS
(достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые
импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py),
import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в
product_handlers.py — job-тела kit-handler'ов).
main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок
запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`);
прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код.
scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false →
legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь.
Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore"
защищает от startup-краха на leftover env var), но на ветвление не влияет.
app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/
после этого PR (только тесты, которые Part E удалит вместе с самим файлом) —
подтверждено grep. scrape_pipeline.py не тронут (Part E).
Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер,
backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py /
test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант
против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py).
Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_
wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами
task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми.
test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты).
test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против
удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/
test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/
registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не
app.services.scheduler, а сам scraper_kit.orchestration.scheduler.
test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop,
переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR).
test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из
scrape_pipeline) намеренно НЕ тронуты — Part E.
Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех
изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
После миграции admin.py на scraper_kit.orchestration.pipeline (предыдущий
коммит) 4 endpoint-теста продолжали патчить устаревший
app.services.scrape_pipeline.run_* — имя, на которое admin.py больше не
ссылается. Мок не перехватывал вызов → FastAPI BackgroundTask реально
запускал kit-sweep (сетевой I/O), что локально (без :5432) тихо падало
на подключении к БД и глоталось except'ом, а на CI грозило зависанием/таймаутом.
Перенацелены patch-таргеты на app.api.v1.admin.run_* (namespace, куда
забинжен kit-импорт):
- test_city_sweep.py::test_start_endpoint_ok -> run_avito_city_sweep
- test_cian_city_sweep.py::test_cian_start_endpoint_ok -> run_cian_city_sweep
- test_yandex_city_sweep.py::test_yandex_start_endpoint_ok -> run_yandex_city_sweep
- test_sweep_imv_phase.py::test_sweep_endpoint_passes_enrich_imv -> run_avito_city_sweep
Добавлены DI-guard assertions (закрывают coverage-gap: раньше проверялся
только HTTP 200, не факт вызова kit с правильным DI) — mock_sweep.call_args.kwargs
проверяется на RealScraperConfig/RealMatcherAdapter/RealEnrichmentJobs (там, где
kit-сигнатура их требует) + наличие proxy_provider.