Read-only _is_premium_building(db, house_id) looks up target_house_id in the
premium_houses MV (data/sql/139) and surfaces premium_building +
premium_building_median_ppm2 on AggregatedEstimate. Metadata only for
manual-review routing (#4) — does NOT touch _price_from_inputs / expected_sold /
median / ranges, so the backtest regression gate is unaffected.
MV-missing / SQL errors degrade to (False, None) with a rollback so an estimate
never breaks where the MV does not yet exist.
The "Сети" section table summarised engineering networks and distances, but
users could not see those points on the map. Three root causes:
- Connection-points layer was always empty: NSPD engineering_structures
(cat 36328) arrive as Polygon/MultiPolygon in EPSG:3857 (prod: 587 Polygon
+ 298 MultiPolygon, zero Point), but extractLatLon only handled Point in
EPSG:4326. Extend it to compute the outer-ring centroid for
Polygon/MultiPolygon and reproject 3857→4326 (frontend math, no backend
change), so the 10+ connection points render as markers with popups.
- Map vs table coverage mismatch: the table counts "В 2 км" while the OSM
utility-infrastructure layer fetched only 500 m (1 object inside 500 m vs
153 within 2 km). Raise the hook default to a shared
UTILITY_COVERAGE_RADIUS_M = 2000 so the map matches the table column.
- Coherence microcopy: clarify that the map shows the same networks as the
table (2 km), plus NSPD connection points and protection zones.
Adds unit tests for extractLatLon polygon-centroid + 3857→4326 reprojection.
Refs #1961
Replace dev-jargon with plain RU across the §22 forecast report (6.2/6.3/6.5
+ deficit/затоварка legend). Source of truth = backend reason strings + the
frontend RU maps; text/render-only, no scoring/forecast math touched.
Backend (source of truth):
- scenarios.py _COLLAPSE_REASON_LOW_BETA: «β rate-sensitivity не прошёл gate …»
→ «чувствительность к ставке не оценена на коротком ряде ЕКБ → один базовый
сценарий вместо трёх».
- confidence_engine.py _coverage_factor: drop «domrf↔objective» jargon, say it
affects будущее предложение/конкуренцию. New _history_factor: «глубина истории
N мес» + на что влияет + связь с 6.2 (короткий ряд → один сценарий).
Frontend (both Section-6 families — live analysis page + ptica cockpit):
- Deficit legend: −1 затоварка / 0 баланс / +1 острый дефицит + actionable
трактовка; MOI tied to «сколько месяцев район распродаёт предложение».
- 6.2 heading «Почему один сценарий, а не три» over the collapse reason.
- 6.3 render confidence.rationale + weakest-link rule («скорее завысим
недоверие, чем недооценим риск»); FACTOR_RU gains confounded_window/
advisory_cap; factor notes shown.
- 6.5 «Вес»→«Оценка»; overall verdict vs 0.5; «Риск избытка предложения»→
«Запас по предложению»; §-refs moved from reason into tooltip; 6 special-
index 1-line «что это + куда лучше» descriptions; 0.00-score reasons shown.
Tests: confidence_engine (history/coverage notes), stripSectionRefs vitest.
Refs #1963
Locked/no-access users (403/denied path/expired session/trial ended) were
trapped: NoAccessScreen had no logout control, and «Выйти» lived only inside
UserMenu, which does not render on the no-access screen. They could not switch
accounts without clearing browser basic-auth manually.
- Extract logout() into shared `lib/logout.ts` (single source of truth).
- UserMenu now imports it instead of a local copy (behaviour unchanged).
- NoAccessScreen renders an always-available «Выйти» accent button for every
variant.
Mirrored across both frontends (tradein-mvp/frontend + main frontend) to keep
the MIRROR invariant in sync.
Light report (Section1ParcelInfo) only rendered the dead top-level
zoning.data_available=false (closed-PKK6 path) and never showed the
resolved nspd_zoning, so the resolved regulation (synth #1891) was
invisible to the user («мы выгружали НСПД — где они?»).
- Add «Градрегламент (НСПД)»-card to Раздел 1 (right column, под ЕГРН):
тер.зона, КСИТ/max_far, пред. высота, этажность, коэф. застройки,
КСИТ-ёмкость/пятно (от площади ЕГРН), источник + список разрешённых
ВРИ зоны. Empty-state когда регламент не определён (gap-зоны / не-ЕКБ).
КСИТ-microcopy расшифровывает жаргон на первой странице.
- New pure mapper adaptNspdRegulation (nspd-regulation.ts) reused by
ptica-adapt so light report и /ptica остаются consistent.
- coerceFloat: max_far/max_building_pct приходят с провода СТРОКАМИ
("2.4"/"80") — фикс латентного бага cockpit'а (typeof===number
глушил реальный строковый КСИТ в massing/insolation).
- Widen NspdZoning.max_far/max_building_pct to string|number, add
main_vri; expose nspd_zoning on ParcelAnalyzeResponse.
- Unit test для field-mapping + coercion + empty-state.
Four frontend display fixes for the Site Finder analysis report (audit #1953);
all consume fields the (already-deployed) backend now returns.
- #1954: ЕГРН «Обременения» row was hardcoded «—». Wire top-level
`encumbrance` block {has_zouit, zouit_count, zouit_types} via
formatEncumbrance → «ЗОУИТ: N (types)» / «не выявлено (НСПД)».
- #1955: show obj_class per success bucket («Студии 15-30 · Комфорт») so
rows with identical area-labels are distinct; fix top-row match + React key
to (bucket, obj_class).
- #1957: NspdZouitOverlapsBlock renders human type_zone + reg_numb_border,
adds a СЗЗ caveat banner (СанПиН 2.2.1/2.1.1.1200-03) and «СЗЗ вашего
участка» highlight; switched raw hex to UI tokens.
- #1958: ForecastChart axis names → nameLocation:middle + nameGap (no legend
overlap); grey flat-state callout when the deficit line is degenerate
(scenarios_collapsed or all points clamped at ±1).
Tests: formatEncumbrance + isDeficitDegenerate (15 new). Real segment
differentiation is backend #1959 (separate).
openapi-codegen-check был RED на каждом PR: CI шаг гонял bare `npx prettier`
(резолвится в плавающий latest), а committed api-types.ts был отформатирован
старым prettier (≤3.6.2, union-типы multi-line). 3.9.0 переносит union-wrapping
на single-line → CI-регенерация всегда давала diff → gate падал.
Чиним детерминизмом одной версии на обоих путях:
- frontend/package.json: добавлен prettier@3.9.0 (exact) в devDependencies
+ обновлён package-lock.json.
- CI openapi-codegen-check: bare `npx prettier` → ./node_modules/.bin/prettier
(project-local pinned, не плавает).
- .pre-commit-config.yaml: prettier hook additional_dependencies=["prettier@3.9.0"]
— тот же движок, что в CI (alpha.8-обёртка байт-в-байт == prettier 3.9.0).
- api-types.ts перегенерирован prettier 3.9.0 (union-типы single-line).
Backend OpenAPI-схема не менялась — только формат. CI-регенерация теперь
байт-в-байт совпадает с committed-файлом и с выводом pre-commit hook.
Миграция 172 убрала english 'Comfort' из v_bucket_success_score (NULL-class
→ 'не указан'), но _bucket_success_ranking в analytics_queries.py матчил
COALESCE(:cls, 'Comfort') — после 172 ни одна строка не равна 'Comfort' →
recommend_mix success-boost (#25, /analytics/recommend квартирография) тихо
возвращал [] (silent degradation). LIVE на проде с момента ручного применения
172 (graceful empty, без краша).
Fix:
- call site (recommend_mix): передаём target_class_db (уже переведённый через
_class_to_db_vocab english→русский), а не сырой english target_class.
Чинит и латентный pre-existing баг: Business/Elite никогда не матчили
русский источник.
- SQL default 'Comfort' → 'Комфорт' (массовый класс ЕКБ, 723 объекта).
NULL-class ('не указан') в default-путь намеренно не попадают
(документировано в docstring).
Prod-verified: OLD 'Comfort' default → 0 строк; NEW 'Комфорт' default → 5.
Tests:
- _bucket_success_ranking: реальный запрос (не замокан) с русским default —
SQL содержит 'Комфорт', не 'Comfort'; ranking непустой.
- recommend_mix: english 'Business' переводится в 'Бизнес' перед ranking
(раньше тест патчил _bucket_success_ranking→[] и баг не ловил;
helper теперь умеет patch_success_ranking=False).
- #1960: позитивный тест выбора quarter_rosreestr basis (deals≥5, все
higher fallback'и None) → median_price_basis='quarter_rosreestr'.
praktika pilot→expired role in roles.yaml (paths:[], deny:/**); RouteGuard
short-circuits on role=expired with a dedicated trial-ended screen (not the
generic path-deny path). NoAccessScreen gains variant="trial" with title
«Пробный доступ закончился» and a Telegram link to @ArtemKopylov87. Backend
Role Literal and frontend Role type extended to include "expired". kopylov
(pilot) unaffected.
FIX A (#1955) «Что хорошо продаётся»: убран фантомный класс 'Comfort'.
- Миграция 172: v_bucket_success_score COALESCE(obj_class,'Comfort')
→ COALESCE(obj_class,'не указан'). Английский литерал заполнял 397 NULL
и сливался отдельным классом от русского 'Комфорт' → визуальные дубли
бакетов в UI. Источник уже канонически-русский (проверено на проде),
synonym-mapping не нужен.
- parcels.py: obj_class протаскивается в success-ranking query + dict.
- TS SuccessRankingBucket.obj_class добавлен.
FIX B (#1960) «Медиана рынка» = 64k (квартальная росреестровская n=1 ДКП):
- district.median_price_per_m2 больше не COALESCE(median_12m, ekb_ref) в SQL.
Basis-приоритет (newbuild-first): Objective по имени района →
geo_radius (Objective в 3км) → ekb_districts reference →
квартальная росреестровская медиана ТОЛЬКО при deals_count≥5.
Для 66:41:0205010:287: 64k → 132690 (geo_radius, newbuild-consistent).
- median_price_basis добавлен в payload + TS type (nullable median).
- Frontend null-guards для нового nullable median.
Tests: +4 (geo_radius basis, objective-приоритет, deals-guard, obj_class
passthrough); обновлены district-моки в 9 analyze-тестах под новую
SQL-сигнатуру.
Behavior-preserving structural refactor: the ~870-line deterministic pricing
block inside estimate_quality() is extracted into a new pure synchronous function
_price_from_inputs() returning a PricingResult dataclass.
Key design decisions:
- All async DB fetches (imv_eval, yandex_val, cian_val) hoisted to estimate_quality
BEFORE the call; passed as pre-fetched values / bool flags.
- DB-dependent helpers whose arguments are computed inside the block (_get_asking_sold_ratio,
_lookup_quarter_index, _lookup_quarter_indexes) injected as Callable parameters
(ratio_resolver, quarter_index_lookup, quarter_indexes_lookup).
- _fetch_house_imv_anchor called once in estimate_quality (was two separate conditional
calls inside the block); single result passed as imv_anchor dict.
- _fetch_dkp_corridor and _fetch_anchor_comps hoisted to estimate_quality with
identical guards.
All 2443 existing tests pass UNMODIFIED. 9 new hermetic unit tests added that
call _price_from_inputs directly with stub callables.
Code-review nits (без изменения поведения):
- quarter_dump_lookup.py: комментарий про дедуп ошибочно утверждал, что зоны без
рег-номера не схлопываются. На деле reg_numb_border — TEXT NOT NULL (0 NULL на
проде), а DISTINCT ON в PG считает NULL равными. Переформулировано на точное.
- frontend/types/nspd.ts: убран устаревший 'cad_zouit' из списка возможных
group_key (бэкенд теперь эмитит только protected/engineering/okn/natural/other).
#1954 — площадь «—»: COALESCE(land_record_area, specified_area, declared_area)
в EGRN-блоке analyze (land_record_area NULL у 12809/42233 участков, но
specified_area заполнена). area_m2 для 66:41:0205010:287 теперь 106378 (был NULL).
#1954 — «Обновлено» сломано: cost_registration_date — мёртвая колонка (0/42233);
репойнт на updated_at (42233/42233 заполнено). Ключ ответа last_egrn_update_date
не меняется (additive value fix).
#1958 — confidence-фактор «Прогноз спрос/предложение» дублировался ×4
(по фактору на горизонт). Сворачиваем в один weakest-link'ом (MIN ранга
по горизонтам) в _component_confidences до confidence-движка (#990).
#1957 — ЗОУИТ backend:
- _get_cad_zouit_overlaps: DISTINCT ON (reg_numb_border) — дедуп дубль-строк
(одна физ. зона 2× с разным category_name). group_key cad_zouit→protected.
- _get_zouit_overlaps (dump path): subcategory→RU-тип карта (26→СЗЗ и др.,
коды сверены кросс-джойном dump↔cad_zouit на проде); type_zone из карты,
reg из props.options.reg_numb_border. Раньше отдавал blank-строки.
- унификация group_key (protected/engineering/okn/natural/other) + top-level
reg_numb_border в обоих путях.
UP038-модернизация isinstance в report_assembler (pre-commit ruff 0.7.4).
Frontend note (#1957): nspd_zouit_overlaps теперь всегда group_key из набора
{protected,engineering,okn,natural,other} — сырой 'cad_zouit' больше не отдаётся;
оба пути несут type_zone + reg_numb_border.
Tests: +4 _component_confidences collapse, +6 ЗОУИТ (subcategory map, dump
typing, DISTINCT ON), schema-test обновлён на protected. 451 passed targeted.
Два связанных фикса из диагностики 2026-06-27:
ФИКС C (run_cian_full_load, per-fetch timeout): monkey-patch _browser.fetch →
asyncio.wait_for(_f(url), timeout=cian_full_load_per_fetch_timeout_s=90s).
BrowserFetcher.fetch имеет httpx-timeout 120s, но browser-сервис иногда виснет
так что httpx не получает ответ (нет EOF) → fetch висит → asyncio.gather
блокирует весь bucket → heartbeat_at не обновляется → reap_zombies убивает живой run.
wait_for(90s) отменяет зависший fetch → TimeoutError → _fetch_page_html ловит
как Exception → None → _one_page → [] → gather завершается нормально.
За флагом (cian_full_load_per_fetch_timeout_s=0 = отключить, >0 = включить).
ФИКС D (background heartbeat): asyncio.create_task(_background_heartbeat())
обновляет heartbeat_at каждые 60s независимо от on_bucket/on_progress прогресса.
Без него пустые bucket или зависший gather замораживает heartbeat на несколько часов.
Task отменяется в finally → не течёт при return/raise/cancel.
Новая настройка (ENV):
CIAN_FULL_LOAD_PER_FETCH_TIMEOUT_S=90.0 — timeout одного browser-fetch
Два связанных фикса из диагностики 2026-06-27:
ФИКС A (_rotate_proxy_ip): вместо одношотного GET с timeout=30s — retry-loop
(proxy_rotate_attempts=3 попыток по proxy_rotate_attempt_timeout_s=8s каждая).
Зависший changeip-сервис больше не блокирует весь sweep на 30s.
Первый успешный attempt → True; все провалились → False (логируется как error).
ФИКС B (run_avito_city_sweep): когда SERP-фаза уже сохранила лоты
(lots_inserted + lots_updated > 0) и упала только detail/houses-фаза
(AvitoBlockedError / RateLimitedError), помечаем run как done (не banned)
при avito_serp_ok_not_banned=True (default). Метрика banned зарезервирована
для случаев где SERP сам заблокирован (0 лотов).
Новые настройки (ENV):
PROXY_ROTATE_ATTEMPT_TIMEOUT_S=8.0 — timeout одной changeip-попытки
PROXY_ROTATE_ATTEMPTS=3 — число попыток перед отказом
AVITO_SERP_OK_NOT_BANNED=true — флаг нового поведения (default on)
The data-freshness monitor classified by run RECENCY only, so the domrf_kn
FLATS loader running status=done but extracting 0 flats for ~5 weeks went
undetected — and the kn source watched objects_count (healthy ~1548), not
flats_count (the broken =0 metric).
Add an opt-in zero-output check: an otherwise-fresh run-ledger source (recent
success, would-be fresh by age) that produced 0 work-rows in the 7d window is
downgraded to status="failed" (so scrape_freshness_check alerts), with an
additive "reason". Guards: alert_on_zero_output flag, run-ledger only
(timestamp_col is None), status=="ok" (age-stale/failed already covered), and
upd_7d==0 (SUM of the source's own work_col over done-runs).
Registry: new kn_flats source (kn_scrape_runs, work_col=flats_count, critical,
flag on) — watches the column that was broken; existing kn (objects_count)
unchanged. Flag also enabled on objective (rows_lots, critical). nspd/nspd_geo/
cadastre left unflagged (legitimate-0 / data-table).
JSON additive only (new nullable "reason" key; endpoint is dict[str,Any], no
frontend consumer / no codegen needed). 4 new tests (downgrade, no-false-
positive, age-precedence, registry). code-reviewer APPROVE.
Would have caught #1945 within ~8-14d instead of 5 weeks.