#2488 (_resolve_target_city + LOWER(d.city)=:target_city) смёржен НА ВЕРХ моего #2489
(substring :address ILIKE '%'||d.city||'%') → в _fetch_dkp_corridor образовался
двойной city-фильтр (benign, но избыточный + латентный ё/е edge в substring).
- estimator._fetch_dkp_corridor: убран мой substring-фильтр, оставлен #2488
_resolve_target_city (словарь ~30 городов обл.66 вкл. ЕКБ + все sweep-города;
city=None → фильтр не применяется — прежнее #2488-поведение). Убран unused :address bind.
- api/v1/trade_in.get_street_deals: substring заменён на тот же _resolve_target_city
паттерн (консистентность; #2488 не трогал street-deals). city_filter — литерал,
значение bind-параметром (не инъекция).
Live-verified: Ленина 2к ЕКБ → median 118052, 1 город (байт-в-байт как substring).
Regression-гейт байт-зелёный (коридор заморожен в фикстуре), 53 теста, ruff clean.
- Гистограммы флагман-карточки (ОБЪЯВЛЕНИЯ) и ДКП/сделок биннятся по
outlier-guarded пулу (guardPriceOutliers().clean) — консистентно с числом
«выброс исключён» и спредом на той же карточке. Раньше bins8 спанил
[min,max] ВКЛЮЧАЯ выброс 872k, схлопывая реальные аналоги в левые бины.
- Ось детального scatter «СРОК ПРОДАЖИ» → «СРОК НА РЫНКЕ»: для активных лотов
x = дней на рынке (today−listing_date, нижняя граница), а не измеренный
срок продажи. Обновлены title/aria-label/подзаголовок карточки.
- <1024px: honest desktop-only notice вместо нечитаемого ~25%-scaled артборда
(client-side width guard, ранний return после всех хуков; десктоп не тронут).
- WCAG 2.1.1: точки истории цен теперь фокусируемы с клавиатуры
(role=button / tabIndex / Enter+Space / onFocus-onBlur), мышиный hover сохранён.
#2487 avito oblast per-city sweep был dead-on-arrival: _parse_html дропал все
карточки без /ekaterinburg/ в source_url, поэтому oblast-sweep по городу вне ЕКБ
терял 100% выдачи. Kept-slug теперь параметризуется через AvitoScraper.target_city_slug
(дефолт "ekaterinburg" — ЕКБ-поведение неизменно), прокинут scheduler →
run_avito_city_sweep → AvitoScraper. avito_serp_ekb_only НЕ отключается.
Bug #2 (oblast bare-street mis-bucket): голые адреса 'ул. Ленина 100' в Н.Тагиле
мис-баккетились в одноимённые ЕКБ-дома через Tier-2b (match по глобально-уникальному
normalized_address без geo/city guard). Добавлен coarse-guard: coords present →
ST_DWithin ≤3км до geom дома; coords absent → требуется city-token (self-
disambiguating); иначе skip → консервативно New house вместо мис-матча. _insert_alias
при коллизии normalized_address больше не перебивает fingerprint чужого дома (иначе
guard деградировал бы в Tier-2a-дыру).
Оба дефекта латентные (oblast per-city schedules отключены) — правка делает
capability безопасной к включению, без влияния на прод сегодня.
rbac_guard гейтил только /api/v1/admin/* → revoked (role=expired, roles.yaml
paths:[] deny:/**) и узко-скоупленные аккаунты сохраняли полный non-admin
API-доступ (напр. POST /api/v1/search — экспорт листингов + estimate-quota).
is_path_allowed (roles.yaml paths/deny) существовал, но НЕ вызывался (0 callers).
Fix: после admin-гейта вызываем is_path_allowed(role, external_path) для всех
non-bootstrap путей. roles.yaml globs — внешние (Caddy срезал /trade-in), поэтому
восстанавливаем внешний путь (_EXTERNAL_PREFIX + path). Bootstrap-пути /me и
/brand/* исключены — expired ДОЛЖЕН получить role=expired через /me (trial-экран)
и брендинг; без исключения trial-UX сломался бы (expired paths:[] → 403 на /me).
На сбой парса — fail-open + громкий лог (не лочим платящего pilot из-за конфиг-бага).
roles.yaml НЕ меняю — только энфорсю уже задекларированную политику. pilot/admin/
analyst доступ сохранён (verified), expired теперь 403 на non-admin API.
Тесты: +5 scope-кейсов (expired denied search/trade-in, allowed me/brand; pilot/
admin/analyst preserved) — 31 passed. Тест-harness middleware — mirror, обновлён.
A. ДОМ.РФ year backfill: авторитетный commission_year больше не экранируется
невозможным существующим year_built. Валидное существующее значение
выигрывает (COALESCE-семантика #2013 сохранена), но NULL/impossible
(< 1850, > текущий+2, 0) заменяется валидным commission_year → zhkh_year.
parse_int_field получил sanity-гейт [min,max] — мусорный commission_year
не попадает в staging (7 таких строк в текущем staging).
B. propagate_listings_year: добавлен link-consistency guard. Раньше копировал
houses.year_built на listings по house_id_fk без проверки — перепривязанный
FK впрыскивал чужой когортный год. Теперь пропагация только если координаты
объявления в пределах 500м от геометрии дома (ST_DWithin), либо (без коорд.)
консервативный address-фоллбек по short_address. Live: блокирует 1 из 10
текущих кандидатов (>500м mislink).
C. rosreestr_dkp_import: per-row INSERT-ошибки отделены от dedup-skip. Раньше
except инкрементил тот же batch_skipped, что и легитимный ON CONFLICT — сбой
маскировался под дедуп и run рапортовал success. Отдельный rows_errored +
гейт по доле ошибок (> 5% → run FAILED, не silent-green).
D. rosreestr_dkp_import: ON CONFLICT DO NOTHING → DO UPDATE изменяемых сырых
фактов Росреестра (price/area/rooms/floor/year/deal_date/...), чтобы
исправленный/переопубликованный квартал обновлялся. Обогащение
(lat/lon/geom/geocode_tried_at и пр.) не в SET-списке — не затирается.
IS DISTINCT FROM guard сохраняет идемпотентность resume; RETURNING (xmax=0)
отличает insert от update (rows_inserted vs rows_updated).
sanitize_image декодировал изображение целиком (img.load) ДО ресайза,
а Pillow MAX_IMAGE_PIXELS оставался дефолтным (~89 MP). Хорошо сжимаемый
≤10 MB аплоад с заявленными ~89-178 MP разворачивался в raw RGB буфер на
сотни MB до thumbnail — OOM-kill backend'а (768 MiB, #2214), усиление ×12
слотов на estimate.
Defense in depth:
- _MAX_PIXELS = 40 MP: проверка img.size (из хедера, без декода) ДО img.load;
превышение → ImageSanitizationError без загрузки полного буфера.
- Image.MAX_IMAGE_PIXELS понижен до 40 MP — Pillow сам бросает
DecompressionBombError на любом декод-пути в обход явной проверки.
- Явный except Image.DecompressionBombError + re-raise ImageSanitizationError
(byte/pixel cap) до широкого except — грациозный reject, не 500.
- _MAX_BYTES backstop (endpoint уже режет 10 MB → 413, но сервис standalone).
Контракт caller'а не меняется: reject → ImageSanitizationError → HTTP 400.
Тест tests/services/test_image_sanitizer.py: happy-path, resize, pixel-flood
без декода (load не вызывается), byte-backstop, граница cap, garbage bytes.
Отчёт печатал измерения, которых не делал:
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).
C2 city-scope коридора был NO-OP для не-ЕКБ: estimate_quality резолвил город из geo.full_address, но геокодер РОНЯЕТ город для не-ЕКБ («Ленина, 1» вместо «Нижний Тагил, Ленина, 1») → target_city=None → коридор либо unscoped, либо None. Verified на проде: Н.Тагил corridor=None.
Фикс: резолвить город из payload.address (город есть) с fallback на canonical/geo; коридор матчить тоже по payload.address. GET: row.address приоритетнее canonical.
Verified прод-диагностикой: corridor(address=payload.address, city=resolve) → Н.Тагил=9, ЕКБ=15 (было: Н.Тагил=None). EKB regression-гейт байт-зелёный. geocoder full_address city-strip (display «Ленина,1») → cosmetic, E.
Миграция 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, уже сидящих в источнике, ранее резавшихся на импорте.