Владелец попросил вывести продукт в Графану — до этого там были только
технические панели (запросы/латентность/память). Список счётчиков взят из
реально пишущихся событий, а не выдуман:
Мера (tradein-mvp/backend/app/observability/metrics.py):
- mera_estimates_total{outcome=ok|insufficient_data} — POST /estimate,
зеркалит user_events.event_type=estimate_request (294 строки в БД),
insufficient_data — не ошибка, а исход без аналогов.
- mera_address_suggestions_total{found=yes|no} — GET /geocode/suggest,
своего user_events-события у ручки не было.
- mera_reports_exported_total (без лейблов) — GET /estimate/{id}/pdf.
- mera_leads_total (без лейблов) — POST /trade-in/lead.
- mera_support_messages_total{channel=web|anon} — POST /support/messages
и /support/anon/messages, счётчик после успешной доставки в Telegram.
- mera_logins_total{result=success|failed} — рядом с user_events
login_success/login_failed в auth.py (97/453 строк в БД).
Птица (backend/app/observability/metrics.py):
- sitefinder_reports_exported_total{format} — GET .../forecast/export
(md/json/tg/docx/pptx/pdf) и POST .../best-layouts/pdf.
Метки везде — фиксированный литерал из места вызова (outcome/found/channel/
result/format), никогда username/адрес/estimate_id/кадастровый номер —
это ровно то, что взрывает кардинальность ряда у Prometheus.
Дашборд ops/metrics/grafana/dashboards/product.json ("Продуктовые метрики",
uid gendesign-product) — воронка Меры (оценки/подсказки/лиды/отчёты/входы/
поддержка) + экспорт форматов Птицы, часовые increase()-панели без
стекирования (на соседней панели оно уже давало ложную тревогу, PR #3474).
Provisioning тот же, что у apps.json — сканирует директорию, отдельного
конфига не нужно.
ops/metrics/alloy/alloy-apps.alloy проверен: у job "apps" нет relabel-
фильтра по __name__ (в отличие от cadvisor) — новые счётчики уходят в
remote_write как есть, правки не потребовалось.
Refs #3471
Публичный `/suggest` и кабинетный `/api/v1/geocode/suggest` всегда звали
геокодер с `region_code=66`: публичная ручка регион не передавала вовсе,
а у кабинетной он был обязательным параметром со значением по умолчанию.
`city_hint="Москва"` на это не влиял — DaData и Nominatim получали
свердловский hard-констрейнт и молча возвращали ПУСТО. С сайта и из
кабинета московский адрес просто нельзя было ввести, хотя оценка,
проба покрытия и реестр регионов Москву уже поддерживают.
Добавлен `effective_region_code()`: явный `region_code` важнее вывода из
`city_hint`, вывод идёт через существующий реестр `app.services.regions`
(`REGIONS[77].cities` содержит «москва»), последний рубеж — прежний
`DEFAULT_REGION_CODE`. Отдельного списка городов не заводим: разъехаться
двум спискам — вопрос времени.
Поведение сегодняшних клиентов не меняется байт-в-байт: без `city_hint`
и с любым свердловским городом регион по-прежнему 66. Публичная схема
принимает `region_code` на будущее — если фронт когда-нибудь начнёт его
слать, он будет приоритетнее хинта; неизвестный регион как и раньше
отдаёт 422 из геокодера, а не 500.
Тесты: четыре инварианта на сам хелпер (нет хинта → 66; свердловский
город → 66; Москва → 77; явный 66 поверх Москвы → 66) и по одному на
каждую ручку — что вниз по потоку уезжает ожидаемый регион. Прежние
тесты region-скоупа геокодера не тронуты.
189 passed в связанных файлах, ruff чистый.
`asyncio.to_thread` отменить нельзя: по истечении бюджета (`_with_budget` =
`asyncio.wait_for`, у геокодера 12 с) снимается только ожидание со стороны
loop'а — поток продолжает работать с ТОЙ ЖЕ `Session`, что и весь запрос.
Вызывающий тем временем идёт дальше: следующий источник, `_fetch_anchor_comps`,
`_persist_estimate_and_commit`. Два потока в одной `Session` дают «another
operation is in progress» / InvalidRequestError на СЛЕДУЮЩЕМ шаге. У источников
эту ошибку глушит `except` вокруг вызова, у персиста оценки не глушит никто —
500 и потерянная оценка клиента.
`app/core/db.py: run_db_thread` — ТОЛЬКО защита от сироты: `ensure_future` +
`shield`, на отмене дождаться потока (`asyncio.wait`), прочитать
`step.exception()` (иначе asyncio печатает «Task exception was never
retrieved» без контекста) и пробросить отмену. Commit/rollback туда НЕ вынесены:
посреди геокодинга commit зафиксировал бы частичное состояние оценки.
`estimator._db_step` переписан поверх и добавляет свои commit/rollback сам —
его поведение не меняется, гейт tests/test_3408_db_step_cancel_orphan.py
остаётся зелёным.
Заменено 34 вызова, работающих по сессии запроса: 12 в geocoder.py (кэш-чтение
и записи, геопортал, кадастр, houses, reverse, suggest), 19 в estimator.py
(в т.ч. `_backfill_house_fias`, `_save_yandex_history_items`,
`_fetch_anchor_comps`, `_price_from_inputs` с db-резолверами, персист оценки,
`_fetch_price_trend`, `_is_premium_building`), 2 в api/v1/geocode.py, 1 в
api/v1/privacy_admin.py. Не тронуты вызовы со СВОЕЙ сессией:
`user_events.schedule_event` (внутри `record_event` свой `SessionLocal`) и
`sber_index` (сессия задачи планировщика, отменять её некому).
Гейт по значению — tests/test_3449_geocoder_cancel_orphan.py: отмена по бюджету
во время шага БД геокодера, следом ГОЛЫЙ `to_thread(db.execute, ...)` (образец
персиста); проверяется, что он не вошёл в сессию, пока сирота ещё в ней.
На исходном коде тест краснеет: conflicts == 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end.
1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py).
Переиспользует штатный save_listings из кита: писатель уже параметризован
регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot
один в один, так что импорт сводится к сборке модели и вызову писателя.
Москва отбирается по префиксу административного округа в адресе, а не по
bbox. Причина: адрес Циан не содержит города, а границы региона 77
захватывают ближний пояс области. Замер по проду: с округом 35 552, все
внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно
отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера.
listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload.
Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по
всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000,
отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса.
В проде есть гвард (estimator.py): в аналоги идут строки только с
listing_segment IS NULL или 'vtorichka' — копирование метки как есть
выбросило бы 29 000 строк из подбора.
Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни
города, ни округа, координат нет ни у одной из 50 335 карточек, отличить
область от Москвы нечем.
Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией,
35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не
дублирует — idempotency на dedup_hash, проверено.
2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode).
Раньше suggest вообще не принимал регион: DaData звалась с жёстким
region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский
адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано
как известный баг. Механику по регионам переиспользовали из geocode(),
вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных.
3. Оценка перестала геокодировать Москву свердловским скоупом (estimator).
geocode() звалась без региона, то есть с дефолтом 66, и московский адрес
возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими
подсказками. Регион запроса определяется по координатам через реестр, затем
по city_hint, затем дефолт. Fast-path клиентских координат стал
региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают
байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы.
Регресс-нейтральность по Свердловской области — главный критерий всех трёх
кусков. Тесты: 1160 passed по затронутым областям.
Известные ограничения. Границы 77 захватывают ближний пояс области, Химки
резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка
поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
Раньше _yandex_lookup/_yandex_suggest/_nominatim_suggest молча подставляли
"Екатеринбург, " в запрос, если в адресе не было маркера города/области.
Житель Нижнего Тагила, вводя «Ленина, 1», получал уверенно неверную цену по
екатеринбургской улице Ленина (обе улицы называются одинаково) — фронт город
вообще не передаёт.
- geocode()/suggest() принимают опциональный city_hint: str | None; без него
внешние тиры больше НЕ подставляют город, а bias (ll/spn) смещается на всю
область (OBLAST66_VIEWBOX) вместо ЕКБ-центра. Явный маркер города в адресе
или city_hint сохраняют прежнее поведение (ЕКБ-путь не деградирует).
- GeocodeResult.city_ambiguous — честный флаг «город определил провайдер, а
не пользователь» (не эвристика на корректность), проброшен в
AggregatedEstimate.target_city_ambiguous (ephemeral, не персистится).
- Cache-ключ geocode_cache учитывает city_hint (address|city=...) — без hint'а
формат не меняется (backward-compat), с hint'ом разные города для одного
текста адреса больше не делят одну запись.
- API: /api/v1/geocode/lookup, /suggest и POST /trade-in/estimate получили
опциональный city_hint — контракт не ломается (default None).
23 новых теста в test_geocoder_city_hint.py; проверено что они падают
(ImportError на _cache_key) на коде до фикса через git stash.
Геокодер был жёстко 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-оценки адресов области.
Replaces tradein.cad_buildings snapshot with live postgres_fdw foreign table reading gendesign.v_tradein_cad_buildings. Fixes /trade-in/api/v1/geocode/reverse 500 (Nominatim ban) and address_not_geocoded for cadastre addresses (e.g. Хохрякова 81).
Security (deep-review fixes):
- 100_tradein_fdw_role.sql: passwordless CREATE ROLE; password set by deploy.yml ALTER ROLE bootstrap reading GENDESIGN_FDW_PASSWORD from backend/.env.runtime (via psql :'pw' var → format %L — injection-safe).
- core/fdw.py: regex whitelist [A-Za-z0-9_-]{32,256} on password, ValueError without echoing value, try/rollback on commit.
- 060_postgres_fdw_extension.sql: connect_timeout='3' on FOREIGN SERVER + ALTER ADD/SET fallback.
- geocoder.py: _cadastral_forward_sync / _cadastral_reverse_sync wrapped in asyncio.to_thread.
- 100_*.sql: REVOKE ALL ON ALL TABLES/SEQUENCES/FUNCTIONS IN SCHEMA public; only GRANT SELECT on v_tradein_cad_buildings.
- pg_user_mappings query handles PUBLIC mapping (usename IS NULL).
Tests: 3 SQL-injection guards on ensure_fdw_user_mapping + rewritten cadastral suite.
Кнопка справа от поля адреса → модалка с картой Екатеринбурга.
Клик по дому → reverse-геокодинг → адрес подставляется в форму.
- geocoder.reverse_geocode + GET /api/v1/geocode/reverse — Nominatim
/reverse (координаты → адрес).
- MapPicker.tsx — модалка с Leaflet + OSM-тайлами (Leaflet с CDN,
без npm-зависимости), circleMarker по клику.
- EstimateForm — кнопка-карта справа от AddressInput.
Карта на OSM (без API-ключа). При появлении ключа Yandex (#402)
можно перейти на Я.Карты.
Closes#415