Остаток аудита #1871 (#1926), тема data-honesty #1953.
- invest_score: machine-readable score_is_advisory + score_advisory
{kind=poi_density_heuristic, scale_min/scale_max=SCORE_MAX_REFERENCE} в
analyze-payload + AnalyzeResponse (optional, backward-compat) + регенерён
api-types.ts. Явно помечает score как POI-эвристику, не абсолютный рейтинг.
- velocity (Section3 VelocityBlock): явный провенанс объёма — бейдж
«Источник: Objective» + видимый --warn-soft caveat когда объём по прокси/
кад.кварталу, а не прямые продажи Objective. Поля провенанса уже доходили.
- a11y: <h1> во всех состояниях страницы анализа (был только в success).
- cleanup: удалён неиспользуемый export confidenceLevelToValue (latent risk).
units_sold-пункт #1926 закрыт ранее (#2054 inline _SOLD_COUNT_SQL).
По фидбэку: куцый inline-скаттер в 02 РЕЗУЛЬТАТ мало что говорил и дублировал
полный price×time график в Аналитике. Удалён; ряд диапазонов теперь grid 1fr 1fr —
два range-бара (объявления/сделки) на всю ширину, читаются прямее. Полный
точечный график остаётся в оверлее Аналитики (таб 06).
- ResultPanel.tsx: удалён scatter-блок (title + SVG + легенда + «Подробнее»),
grid ряда «1fr 168px 1fr» -> «1fr 1fr»; убраны ставшие мёртвыми токены
scatterDeal / line3.
- data.scatterMini в контракте оставлен (маппер всё ещё считает, просто не
потребляется) — чистку контракта можно сделать отдельно.
tsc + next build green.
#3: переключатель окна метрики (now/45д) — меняет показываемый %
(sale_share_pct↔sale_share_pct_45d), счётчик (active_secondary↔listings_45d) и
сортировку (share_desc↔share_45d_desc, active_desc↔count_45d_desc). 45д-данные
уже были в API — фронт их не показывал (потому тумблер и не находился).
#2: поле «Мин. объявлений в доме» — client-side фильтр по оконному счётчику.
Frontend-only (бэк/API/deps не тронуты). typecheck clean.
NB: маркеры карты пока по now-метрике (мелкий follow-up).
Поиск домов (доля квартир в продаже) вынесен в отдельный продукт, отвязан от
бренда «Мера» (Мера = оценка вторички). Только UI + 1 строка Caddy, бэк не тронут.
- app/sale-share/layout.tsx: своя metadata (title «Поиск домов — gendsgn»).
- components/trade-in/SaleShareHeader.tsx: своя шапка — продукт «Поиск домов»,
без MeraMark и Mera-табов; UserMenu (auth) сохранён.
- app/sale-share/page.tsx: Topbar → SaleShareHeader; убраны «Мера» из
breadcrumb/footer. Функционал/API (/trade-in/api/...) не тронут.
- Caddyfile: gendsgn.ru/sale-share → redir /trade-in/sale-share (короткий адрес;
basePath=/trade-in → true vanity-URL требует отдельного app).
C1 (tokens): darken muted scale to WCAG AA (>=4.5:1 vs worst-case canvas
#e6eef7); reclassify primary labels muted->body2 across components.
H1 (mappers): planning chip derived from rooms actually present in the
analog set («Все планировки» when heterogeneous) — no number shifted.
H2 (ResultPanel): make «ОЖИДАЕМАЯ ЦЕНА СДЕЛКИ» the headline (accent
border/tint/glow + larger value); demote ДКП·Росреестр to a muted
СПРАВОЧНО reference tile. All three values + mapResultPanel wiring intact.
H3 (mappers): parseAddress keeps the house number for prefix-less
addresses («Белинского, 86»); prefixed path unchanged.
M4 (page/HeroBar/Footer): gate dead controls (КАК РАССЧИТАНО / PDF /
ДЕЙСТВИТЕЛЕН ДО / footer validity) on hasEstimate (mounted && estimate && !insufficient).
M5 (LocationDrawer): real dialog — role=dialog/aria-modal/aria-labelledby
+ focus-trap + <button> close + Esc preventDefault/stopPropagation.
M6 (ParamsPanel): address autocomplete -> combobox ARIA + Arrow/Home/End/
Enter/Escape keyboard nav (mirrors Dd listbox).
M7 (Sources/Cache/Analytics/History): ARIA table semantics over the grids;
Analytics scroll region keyboard-focusable (clears the serious
scrollable-region-focusable axe violation); chart SVGs get role=img+title+aria-label.
M8 (CacheView): normalize the ВРЕМЯ column (normTime) to consistent minute precision.
M9 (LocationDrawer): honest «как считаем» source list (Циан, Я.Недвижимость,
Авито, N1.ru + Росреестр) — drop dead Домклик/Restate per #1968.
M10 (ParamsPanel/mapMarkers): subject pin shows the subject's own area; drop
analog pins overlapping the subject center (was misread as subject data).
M11 (ObjectSummary/SourcesView): label «медиана объявлений»; hide all-«—» columns/rows.
M13 (page): clamp artboard scale >=0.88 so micro-type doesn't drop below ~8px.
L1 (Analytics): suppress fake «X–Y дн» range when n<2.
L2 (Analytics): drop zero-data series from price-history subtitle.
L7 (TopNav): replace hardcoded #6f8195 icon strokes with tokens.muted.
tsc + next build green. No FE test infra in tradein-mvp/frontend — parseAddress
verified via standalone harness (6/6). Backend data-contract items M1/M2/M3/H4
(source counter, ratio vs medians, bargain bases, cross-source dedup) filed
separately — not FE bugs. L6 (blueprint-grid axe-blindness) deferred: base
gradient + glass surfaces keep axe contrast «incomplete» regardless, so the
authoritative check stays the manual computed-color sweep.
merge_duplicate_houses кластеризовал дома по exact lower(trim(address)) →
варианты написания одного здания (ул. Вайнера,66 vs улица Вайнера, 66) не
схлопывались, расщепляя долю в sale-share. Ключ кластера → tradein_canon_addr
(ул→улица, стрип город/район, КОРПУС сохранён). Гарды против over-merge:
- гео ≤250м (canon стрипует город; прод-дубли Мраморская 34к4 в 222м; города
региона-66 в км+ → 250м безопасно от cross-town);
- canon обязан содержать цифру (номер дома) — исключает вырожденные
«екатеринбург»/«сооружение».
Re-pointing/audit/dry-run/идемпотентность не тронуты. 26 passed.
Три гарда в match_or_create_house (+ P2 в match_house_readonly):
- P1: не матчить/алиасить голую улицу (normalized_address без номера дома)
- P2: отклонять Tier-3 гео-кандидата, если номера домов различаются
- P3: не цементировать слабый гео-матч (0.7) как alias-ключ здания
Хелперы has_house_number/house_number_token (по хвостовому токену —
«улица 8 марта» это улица, не дом). Корень mis-bucketing: house 131237
стянул 8 разных Мамина-номеров + голую улицу. Юнит + поведенческие тесты,
603 matching/normalize/house зелёных. Аффектит только НОВЫЕ матчи;
чистка cemented-данных — отдельной миграцией (P4).
Листинги примагничиваются к НЕ ТОМУ дому (кривой house_id_fk): прод-замер 373
дома (5.5%) / 1685 листингов (7.2%) дальше 300м от своего дома. Пример — house
131237 «Мамина-Сибиряка 126» (ЖКХ=51 кв, знаменатель ВЕРНЫЙ): из 20 активных
листингов реально его ~2, остальные — Свердлова 32 / Азина 31 (до 2.2км) →
ложная доля 39% вместо ~4%.
- мигр.150: v_building_sale_share — числители (active/45д) + медианы считают
только листинги ≤300м от geom дома (ST_DistanceSphere; листинг/дом без geom —
keep). Знаменатель не тронут.
- buildings_query.build_listings_query: тот же гео-фильтр в детальной выдаче
(карточка дома больше не показывает чужие листинги).
Симуляция на проде: меняется 351 дом, обнуляется 164 (чистые «мусорные вёдра»),
числитель −8.3%. Мамина-126: 20→2. Это митигация на уровне view; корневой фикс
house_id_fk-матчера — отдельно. ruff clean, pytest 24 green.
Extend the cooperative shutdown-drain (Phase 2) to the long sweep and
full-load loops in scrape_pipeline.py so they stop CLEANLY on SIGTERM
instead of being hard-cancelled at the 80s _drain_inflight timeout.
At every between-unit checkpoint that already polls scrape_runs.is_cancelled
(user-cancel) we add an ALONGSIDE shutdown_requested() branch:
- anchor-loop sweeps (avito/yandex/cian city) + avito newbuilding +
domclick pre-SERP: update_heartbeat + mark_done(partial) + return;
- full-load on_bucket callbacks (cian/yandex/avito): raise a distinct
RuntimeError("shutdown") sentinel, handled next to the existing
"cancelled" sentinel -> mark_done(partial), skipping the detail phase;
- cian full-load detail loop: break -> existing post-loop mark_done.
Drain finalizes as mark_done(partial), NOT mark_cancelled (that stays
user-cancel only). is_cancelled -> user-cancel semantics are unchanged, and
behaviour is identical when shutdown_requested() is False. No resume_cursor
or paused status yet (Phase 3b, deferred): this is drain-coverage only.
Tests: tests/test_sweep_drain.py covers both structural variants -
run_avito_city_sweep / run_cian_city_sweep (anchor-loop) and
run_avito_full_load (on_bucket sentinel): shutdown flips True after the
first unit, asserting the sweep stops at the checkpoint, calls mark_done
with partial counters, never calls mark_cancelled, and does not process
later anchors/buckets/phases.
- Опц. поля больше не шлют непроверенные дефолты в ценовую модель:
ТИП/РЕМОНТ → «Не указано»→undefined, БАЛКОН tri-state (null пока не
трогали) → undefined.
- Валидация у поля: красная рамка + сообщение под адресом/площадью,
показываются ВСЕ блокеры (не только первый); низ — только серверная ошибка.
- Копирайт «вы»: «если знаете»; кэш-строка → «ДАННЫЕ АКТУАЛЬНЫ ·
ДЕЙСТВИТЕЛЕН 24 Ч»; drawer без жаргона (убраны (asking)/POI).
- Даты сделок Росреестра → «янв 2026» (месяц, не фейк-день 01.01);
бренды источников без method-суффиксов (РОСРЕЕСТР/АВИТО/ДОМКЛИК);
«эталон»→«опорная цена».
- График истории цен: зум оси Y к реальному диапазону данных + динамические
подписи оси; легенда серии показывается только при наличии линии.
- 3 цены весомее (font-weight 400); медиана-маркеры range-баров → тонкий тик
(не выглядит как draggable-ручка); фото здания — синий duotone +
десатурация (не крадёт первый взгляд у цен).
- Таблицы аналогов/сделок: площадь/комнаты/этаж вынесены из адресной ячейки
в колонку ПАРАМЕТРЫ; ↗→› в СВОДКЕ.
Сознательно НЕ тронуто: перераспределение accent-синего (item 9) — axe уже 0,
а широкое снятие акцента конфликтует с «не ломать HUD-палитру»; монотонность
тиров срока продажи не форсируется (это были бы выдуманные данные).
Pre-push review: _await_scheduler ждал только scheduler_loop COORDINATOR, но вся
scrape-работа крутится в detached asyncio.create_task детях (каждый trigger_* делал
`task = create_task(_run())` без join). На SIGTERM coordinator выходил из while True
и завершался → asyncio.run() teardown хард-кансельил ещё бегущих детей mid-await =
ровно #1182 failure mode. Кооперативный checkpoint спасал ребёнка лишь когда его
residual-время случайно перекрывало drain — вероятностно, не гарантированно.
Fix — coordinator теперь дренажит детей перед выходом:
- _spawn_tracked(coro): централизованный detached-spawn, кладёт задачу в module-level
registry _inflight_tasks (strong-ref = RUF006 keep-alive) + done-callback ретривит
exception и убирает из set'а. Заменил 26 одинаковых
`task = create_task(_run()); task.add_done_callback(...)` сайтов.
- _drain_inflight(): один asyncio.wait по живым детям с бюджетом
_CHILD_DRAIN_TIMEOUT_S=80s (< scheduler_main 100s < docker grace 120s). Кооперативные
дети (avito_detail_backfill, rosreestr-executor) дочекивают карточку/батч + mark_done
и резолвятся; некооперативные упираются в timeout и падают на внешний hard-cancel.
- scheduler_loop по выходу из tick-loop (только по SIGTERM) зовёт await _drain_inflight().
NB: raw asyncio.all_tasks()-minus-self здесь НЕЛЬЗЯ — в нашей топологии он захватывает
_run parent-task (блокирован на wait_for(coordinator)) и shutdown_waiter → циклическое
ожидание coordinator↔_run, всегда упирающееся в timeout. Точный registry это исключает.
Tests: tests/test_scheduler.py — detached cooperative child drained-not-cancelled,
non-cooperative child timeout→left for hard-cancel, no-op без детей, scheduler_loop→drain
wiring. Обновил 3 source-inspection теста под новый _spawn_tracked паттерн.
axe-фоллоуап к #2066: «скоро» (КОЭФ.ЛОКАЦИИ) muted2→body2 (2,3→6,3:1 на badgeTint); LocationDrawer aria-hidden когда закрыт (off-canvas вне лендмарка → снимает axe «region»).
В ветке `task.done()` без shutdown глушилось реальное исключение задачи. Зовём
task.result() → пробрасываем настоящий сбой scheduler_loop наружу (loud crash,
как делал прежний `await task`), вместо тихого "exited cleanly".
Деплой (docker recreate tradein-scraper) шлёт SIGTERM с stop_grace_period=120s
(Phase 1). Раньше scheduler_main отвечал hard task.cancel() → бегущий scrape-unit
получал CancelledError посреди браузер-карточки → карточка гибла без COMMIT.
Теперь SIGTERM/SIGINT лишь выставляют кооперативный флаг; tick-loop и длинные
задачи опрашивают его на СВОИХ существующих between-unit checkpoint'ах,
докоммичивают текущий unit и выходят сами.
- app/core/shutdown.py: новый standalone-модуль (asyncio.Event + request_shutdown
/ shutdown_requested / wait_for_shutdown), без app-зависимостей → нет циклов.
- scheduler_main._run: SIGTERM → request_shutdown() вместо task.cancel(); ждём
добровольного drain'а, safety-net wait_for(100s < docker grace) с fallback на
hard-cancel для некооперирующей задачи.
- scheduler_loop: проверка флага в начале тика и после каждого dispatch — не
reap/claim новые run'ы во время shutdown, выходим из loop'а.
- avito_detail_backfill: break на границе карточки рядом с budget-guard; snapshot
pending-query идемпотентен → следующий run сам резюмит остаток.
- import_rosreestr_dkp: расширен is_cancelled checkpoint — drain делает mark_done
(partial), не mark_cancelled; user-cancel семантика без изменений.
Без SIGTERM поведение идентично прежнему. reap_zombies не трогает drained-run'ы
(они mark_done, не 'running').
Tests: tests/core/test_shutdown.py, scheduler_main drain+timeout-fallback,
avito_detail_backfill partial-drain. 26 + 29 scheduler passed.
Quarterly Rosreestr dataset_КАДАСТРСТОИМОСТЬ (cadastral value) gives a
per-cad-quarter cadastral ₽/m². Registered deals priced below 0.7x that
quarter's cadastral median are dropped from mv_quarter_price_per_m2 as
noise (data-entry junk / non-arm's-length deals that drag medians down).
- 178_schema_cadastral_value.sql: rosreestr_cadastral_value (+ staging),
region-66-scoped, idempotent.
- 178b_load_cadastral_value.py: psycopg v3 staging COPY -> typed region-66
INSERT, idempotent skip-if-loaded. Run MANUALLY by operator after deploy.
- 179_mv_quarter_price_cadastral_floor.sql: recreates BOTH MVs (CASCADE
drops the dependent index MV). Adds cad_floor CTE + LEFT JOIN guard to
mv_quarter_price_per_m2; mv_quarter_price_index recreated verbatim.
Safe to auto-apply on prod before any cadastral data exists: empty
rosreestr_cadastral_value -> cad_floor has 0 rows -> floor_ppm2 IS NULL for
every quarter -> predicate keeps ALL deals -> MV byte-identical to the
pre-floor (95) definition. Proven by a rolled-back dry-run (66:% EXCEPT
both directions = 0). With the experiment cad source loaded the floor
moves 480 of 1972 quarters (median 66788 -> 67350).
NN bumped from spec's 172/173 to 178/178b/179 (172-177 already on main).