From 31f101acbba507d8c62fb7e265ca34eb2aa584d2 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 27 Jun 2026 21:40:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein):=20AI-curated=20premium-building?= =?UTF-8?q?=20overlay=20=E2=80=94=20class=20labels=20+=20false-positive=20?= =?UTF-8?q?demotion=20(#2002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/schemas/trade_in.py | 13 +- tradein-mvp/backend/app/services/estimator.py | 62 +++++-- .../sql/142_premium_buildings_curated.sql | 132 ++++++++++++++ .../tests/test_estimator_premium_building.py | 169 +++++++++++++----- 4 files changed, 314 insertions(+), 62 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 6a93e987..ea558765 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -220,14 +220,19 @@ class AggregatedEstimate(BaseModel): # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None # ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ── - # premium_building — целевой дом найден в materialized view `premium_houses` - # (data/sql/139): ~298 EKB-домов, отобранных по концентрации дорогих листингов. - # Это МЕТАДАННЫЕ для manual-review routing (#4) — НЕ влияет на median/expected_sold/ - # ranges. Дефолт False (поведение/сериализация без изменений, когда не премиум). + # premium_building — целевой дом признан премиальным. Источник — curated overlay + # `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false- + # positive demotions), с fallback на MV `premium_houses` (data/sql/139, ~298 + # EKB-домов по концентрации дорогих листингов) для невыверенных домов. Значение — + # уже скорректированное (curated перебивает MV). МЕТАДАННЫЕ для manual-review + # routing (#4) — НЕ влияет на median/expected_sold/ranges. Дефолт False. # premium_building_median_ppm2 — med_ppm2 из MV (₽/м² медиана дорогих листингов дома), # контекст для ревьюера. None если дом не премиальный / MV недоступна. + # premium_building_class — класс дома из curated overlay (премиум/элит/бизнес/ + # комфорт/эконом/неизвестно). None для невыверенных MV-домов / непремиальных. premium_building: bool = False premium_building_median_ppm2: int | None = None + premium_building_class: str | None = None # ── #2002 #4: manual-review recommendation (производный ФЛАГ, НЕ цена) ── # manual_review_recommended — оценку НЕ стоит авто-оффэрить, нужна ручная # оценка человеком (премиальный дом / высокая стоимость / низкая уверенность / diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5be99ba2..f49d7350 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3052,10 +3052,15 @@ async def estimate_quality( else None ) - # #2002: премиальный-дом флаг из MV `premium_houses` для target_house_id. + # #2002: премиальный-дом флаг + класс для target_house_id. Источник — curated + # overlay (data/sql/142) с fallback на MV `premium_houses` (data/sql/139). # МЕТАДАННЫЕ (manual-review routing #4), НЕ ценовой сигнал — не трогает - # median/expected_sold/ranges. Best-effort: degrade в (False, None) при отсутствии MV. - premium_building, premium_building_median_ppm2 = _is_premium_building(db, target_house_id) + # median/expected_sold/ranges. Best-effort: degrade в (False, None, None) при + # отсутствии таблиц. premium_building_class — класс из curated (None для + # невыверенных MV-домов). + premium_building, premium_building_median_ppm2, premium_building_class = _is_premium_building( + db, target_house_id + ) # #audit-2: структурный analog_tier — стабильный enum для фронта. # anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius". @@ -3166,6 +3171,7 @@ async def estimate_quality( analog_tier=api_analog_tier, # type: ignore[arg-type] premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, + premium_building_class=premium_building_class, manual_review_recommended=manual_review_recommended, manual_review_reasons=manual_review_reasons, ) @@ -3182,34 +3188,54 @@ def _qc_geo_to_precision(qc_geo: int | None) -> str | None: return "approximate" -def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None]: - """#2002: флаг «целевой дом — премиальный» из MV `premium_houses` (data/sql/139). +def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None, str | None]: + """#2002: премиальный-дом флаг + класс для target_house_id. + + Источник истины — двухслойный overlay: + 1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой: + per-house class tier (премиум/элит/бизнес/комфорт/эконом/неизвестно) + + is_premium с false-positive demotions. Если дом есть здесь — он ПЕРЕБИВАЕТ MV. + 2. `premium_houses` MV (data/sql/139) — ~298 EKB-домов по концентрации дорогих + листингов; даёт med_ppm2 (₽/м² медиана дорогих листингов) + fallback-членство + для НЕвыверенных домов. + + Возвращает (is_premium, med_ppm2, building_class): + - curated-дом → (curated.is_premium, MV.med_ppm2 или None, curated.tier); + - невыверенный дом в MV → (True, MV.med_ppm2, None) — класс неизвестен; + - дом нигде не найден / house_id None → (False, None, None). - `premium_houses` — ~298 EKB-домов, отобранных по концентрации дорогих листингов. Это МЕТАДАННЫЕ для manual-review routing (#4), НЕ ценовой сигнал: не трогает - median/expected_sold/ranges. Возвращает (is_premium, med_ppm2) где med_ppm2 — - медиана ₽/м² дорогих листингов дома (контекст для ревьюера) или None. + median/expected_sold/ranges. - Best-effort: MV может ещё не существовать в части окружений — любая ошибка - (нет MV / SQL) деградирует в (False, None) и НИКОГДА не валит оценку. + Best-effort: любая из таблиц может ещё не существовать в части окружений — любая + ошибка (нет таблицы / SQL) деградирует в (False, None, None) и НИКОГДА не валит + оценку. """ if house_id is None: - return (False, None) + return (False, None, None) try: - row = db.execute( + cur = db.execute( + text( + "SELECT is_premium, tier FROM premium_buildings_curated " + "WHERE house_id = CAST(:hid AS bigint)" + ), + {"hid": house_id}, + ).first() + mv = db.execute( text("SELECT med_ppm2 FROM premium_houses WHERE house_id = CAST(:hid AS bigint)"), {"hid": house_id}, ).first() - except Exception as exc: # pragma: no cover — defensive (MV может ещё не существовать) - logger.warning("premium_houses lookup failed (graceful): %s", exc) + except Exception as exc: # pragma: no cover — defensive (таблица может ещё не существовать) + logger.warning("premium-building lookup failed (graceful): %s", exc) try: db.rollback() except Exception: pass - return (False, None) - if row is None: - return (False, None) - return (True, int(row[0]) if row[0] is not None else None) + return (False, None, None) + med = int(mv[0]) if mv is not None and mv[0] is not None else None + if cur is not None: # curated overlay перебивает MV (класс + corrected флаг) + return (bool(cur[0]), med, str(cur[1])) + return (mv is not None, med, None) # невыверенный → членство в MV, класс неизвестен def _manual_review( diff --git a/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql b/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql new file mode 100644 index 00000000..80772d7d --- /dev/null +++ b/tradein-mvp/backend/data/sql/142_premium_buildings_curated.sql @@ -0,0 +1,132 @@ +-- 142_premium_buildings_curated.sql +-- Purpose: AI-curated premium-building overlay for the trade-in estimator (#2002). +-- Overrides the concentration-based `premium_houses` MV (data/sql/139) with +-- human/AI-verified class labels + false-positive demotions. +-- +-- Rationale (#2002): +-- `premium_houses` flags a house premium purely by concentration of expensive +-- listings — which mislabels mass-market houses with a few inflated lots, and +-- non-EKB / data-quality-noise listings, as premium. This table is an AI curation +-- of the top premium_houses candidates: 69 confirmed premium (with class tier) + +-- 19 false-positives demoted (is_premium=false: mass-market / non-EKB mislabels). +-- The estimator (`_is_premium_building`) consults THIS table FIRST and falls back +-- to the MV only for uncurated house_ids — so a curated row both corrects the +-- premium flag and supplies a class tier (премиум/элит/бизнес/комфорт/эконом). +-- +-- This is METADATA for manual-review routing — it does NOT touch +-- median/expected_sold/ranges (the backtest regression gate stays byte-identical). +-- +-- Seed: 88 curated rows (house_id, tier, is_premium, confidence, note=left(evidence,200)). +-- ON CONFLICT ... DO UPDATE — curated reference data, re-applied harmlessly on every +-- deploy (idempotent; the latest curation always wins). +-- +-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling. +-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert. + +BEGIN; + +CREATE TABLE IF NOT EXISTS premium_buildings_curated ( + house_id BIGINT PRIMARY KEY, + tier TEXT NOT NULL, + is_premium BOOLEAN NOT NULL, + confidence TEXT NOT NULL, + note TEXT, + curated_at DATE DEFAULT CURRENT_DATE +); + +INSERT INTO premium_buildings_curated (house_id, tier, is_premium, confidence, note) VALUES + (11370, 'премиум', true, 'high', 'desc: ЖК «Гоголя 21», окна в пол, панорамные виды, потолки 3.0м; ppm²=422k (>350k премиум).'), + (12706, 'бизнес', true, 'high', 'desc: ЖК «Клевер парк» (Брусника, бизнес-класс), дизайнерский проект; ppm²=268k.'), + (6769, 'бизнес', true, 'high', 'desc: флагманский проект НКС-Девелопмент, лучшее расположение в центре; ppm²=251k.'), + (13000, 'премиум', true, 'high', 'desc: DOM 7 — премиум-класс от Практики у Екатеринбург-Сити; ppm²=403k (>350k).'), + (376251, 'неизвестно', false, 'low', 'numeric addr, no ЖК; descs — спам (обмен на авто, студия ЖК zoom на Неве/СПб), нерелевантно; ppm²=169k низкий — mass-market с задранными лотами.'), + (6685, 'бизнес', true, 'high', 'desc: Квартал «На Некрасова», новый дом бизнес-класса; ppm²=267k.'), + (7193, 'бизнес', true, 'high', 'desc: ЖК бизнес-класса «Клевер Парк», видовая, дизайнерская эстетика; ppm²=271k.'), + (6976, 'бизнес', true, 'high', 'desc: Квартал на Некрасова, тихий Центр; ppm²=295k (бизнес/элит).'), + (7109, 'элит', true, 'high', 'desc: небоскрёб «Башня Исеть», Екатеринбург-Сити, видовые апартаменты 226-292м²; ppm²=415k.'), + (372575, 'элит', true, 'high', 'desc: Клубный дом инженера Ятеса, элитный, TEN девелопмент, «Екатерининский парк»; ppm²=379k (numeric addr но desc однозначна).'), + (6902, 'бизнес', true, 'medium', 'descs null; ppm² 236504 (бизнес-диапазон 160-250k), n_elite 35; ВИЗ/Репина — кластер бизнес-новостроек (Clever Park/Repin), но конкретный дом 13Ш веб не подтвердил однозначно.'), + (8098, 'бизнес', true, 'high', 'desc прямо: ЖК «Парк Столиц» «инфраструктура уровня business-class»; ppm² 235234 (бизнес), n_elite 29.'), + (6776, 'бизнес', true, 'medium', 'desc: ЖК «Никольский» «жизнь бизнес-класса в центральном районе»; ppm² 194600 (низ бизнес-диапазона), n_elite 29; адрес — район, не дом.'), + (8125, 'бизнес', true, 'high', 'desc прямо: ЖК «Нагорный» бизнес-класса, сдан; ppm² 243523 (бизнес), n_elite 28.'), + (6694, 'премиум', true, 'high', 'desc: «Башня Исеть», 37 этаж, окна в пол, вид на Исеть — флагман центра; ppm² 420517 (>350k премиум), n_elite 27/27.'), + (373396, 'бизнес', true, 'medium', 'desc: ЖК «Атлас» (Atlas Development, «жилая экосистема»); ppm² 238327 (бизнес), n_elite 26; Atlas делает комфорт+/бизнес — линия бизнес по ценнику.'), + (372944, 'комфорт', false, 'medium', 'descs про «г. Нижний Новгород, Александровская слобода» и кирпичный дом 1902г — это НЕ ЕКБ; ppm² 132794 (комфорт), клубность не премиальная — mislabeled.'), + (6958, 'бизнес', true, 'low', 'адрес — улица без дома (агрегация); descs противоречивы (комфорт-студия «Белорецкий» + бизнес «Александровский сад»), но ppm² 293488 (элит-уровень) и n_elite 25 указывают на дорогие лоты (на улице ест'), + (12209, 'бизнес', true, 'high', 'desc: апарт-комплекс «RE:VOLUTION TOWERS», «элитный/видовой апартамент, панорамный вид»; ppm² 236624 (бизнес), n_elite 25 — премиальные апартаменты центра.'), + (12897, 'бизнес', true, 'medium', 'desc: ЖК «Южные кварталы» застройщик Брусника; ppm² 191k попадает в бизнес-полосу (160-250k), хотя проект масштабный комфорт+.'), + (12603, 'элит', true, 'high', 'desc: ЖК «Александровский сад», центр, 3м потолки; ppm² 327k — элитная полоса (250-350k).'), + (6834, 'элит', true, 'low', 'ppm² 254k (элит) + центр, но descs null и адрес без улицы/ЖК — низкая уверенность.'), + (11682, 'премиум', true, 'high', 'desc: ЖК «Форум Сити» (бот.сад, закрытый двор), ppm² 356k — премиум-полоса (>350k).'), + (372939, 'эконом', false, 'medium', 'desc: дом 1984г, газа нет, косметический ремонт, «удачная планировка» — масс-маркет; ppm² 119k (эконом). Ложный флаг по концентрации лотов.'), + (10919, 'премиум', true, 'high', 'desc: Башня Исеть, Екатеринбург Сити, 18 этаж — флагман; ppm² 401k (премиум >350k).'), + (6892, 'бизнес', true, 'low', 'desc упоминает бизнес-класс ЖК «Русь XXI» (TEN) но и комфорт «Дружный-3»; ppm² 175k (бизнес-низ) — агрегат района, смешанный.'), + (6909, 'премиум', true, 'medium', 'ppm² 386k (премиум) + центр, Попова 18; descs null, но уровень цены однозначно премиум.'), + (6712, 'премиум', true, 'high', 'desc явно: ЖК «Форум Сити» премиум-класса (дом 9); ppm² 385k (премиум). Тот же комплекс что hid 11682.'), + (11765, 'элит', true, 'high', 'desc: дом бизнес-класса, пентхаус 5.7м потолки, дизайнерский ремонт; ppm² 304k (элит); тот же адрес ЖК «Александровский сад».'), + (7706, 'бизнес', true, 'medium', 'desc: квартал «На Некрасова» (Брусника) — дома переменной высотности, тихие дворы, подземный паркинг, террасы; ppm2 244k = бизнес уровень.'), + (12285, 'бизнес', true, 'high', 'desc явно: Компаунд «Проспект Мира», премиум-сегмент, монолит 2024, дизайнерский ремонт, мастер-спальни; ppm2 230k = бизнес/элит.'), + (7192, 'премиум', true, 'high', 'desc: клубный дом «Литературный» в центре; ppm2 543k — премиум уровень.'), + (372211, 'премиум', true, 'high', 'desc: клубный дом 19/05 «премиум-класса», видовая, ресепшн+служба безопасности; ppm2 442k = премиум (addr numeric, но desc однозначен).'), + (12480, 'премиум', true, 'high', 'desc: клубный дом премиум-класса «Ленина 8», 28-эт монолит-кирпич 2024, дизайнерский ремонт; ppm2 389k = премиум.'), + (9401, 'комфорт', false, 'medium', 'web: Цвиллинга 7 = ЖК «Парковый квартал 2.0» (Унистрой), комфорт-класс; высокий ppm2 211k и 15 элитных лотов — концентрация дорогих лотов в массовом доме.'), + (372938, 'бизнес', true, 'low', 'ppm2 200k = бизнес-уровень, но addr numeric и descs null — нет ЖК/класс-слов для подтверждения.'), + (11052, 'элит', true, 'high', 'web: ЖК «Гоголя 21» (Брусника) — элитный класс, лобби/библиотека/фитнес в стилобате; ppm2 417k подтверждает.'), + (6978, 'элит', true, 'high', 'desc: ЖК «FORUM CITY» центр, эксклюзивная 7-комн 273 м², дизайнерский ремонт; ppm2 289k = элит уровень.'), + (372937, 'комфорт', false, 'low', 'единственный desc массовый — «однушка переделанная в двушку, мебель/техника остаётся, тамбур с полками»; addr numeric; высокий ppm2 243k вероятно от мелкой площади в старом фонде, не бизнес-дом.'), + (11541, 'бизнес', true, 'medium', 'desc: ЖК «Парковый квартал 2.0» (Брусника, позиционируется комфорт+), но ppm² 241k и 14/19 elite-лотов — пограничный бизнес-уровень цены.'), + (6963, 'бизнес', true, 'high', 'desc прямо: ЖК «Парк Столиц» бизнес-класс; ppm² 227k подтверждает бизнес-band.'), + (8153, 'премиум', true, 'high', 'desc: клубный дом «На Октябрьской», панорама на Исеть, премиум-сервис; ppm² 398k > 350k = премиум.'), + (137631, 'бизнес', true, 'high', 'desc: бизнес-квартал «Нагорный», панорамные окна, топ-застройщик; ppm² 240k = бизнес.'), + (177700, 'комфорт', false, 'high', 'desc: жилой район «Южные кварталы» (ЛСР, массовый комфорт), предчистовая отделка + скидки; ppm² 193k завышен лишь точечно, n=95 — масс-маркет.'), + (7560, 'комфорт', false, 'medium', 'desc: ЖК «Соната Парк» (Астон, комфорт), массовые скидочные тексты; ppm² 167k низ бизнес-границы, generic-addr корзина n=344.'), + (373406, 'бизнес', true, 'medium', 'desc прямо: ЖК «Никольский» жизнь бизнес-класса в центре; ppm² 272k подтверждает; addr только числовой.'), + (373413, 'бизнес', true, 'low', 'descs=null, addr числовой — нет ЖК/класс-слов; вывод только по ppm² 250k (бизнес/элит-граница) + 12 elite-лотов.'), + (372920, 'эконом', false, 'medium', 'ppm² 111k = эконом; descs относятся к Челябинску (панельный дом, студия) — data-quality шум, не ЕКБ-премиум; elite-лоты = крупные дешёвые по ppm².'), + (12747, 'премиум', true, 'high', 'desc: клубный дом «19/05» премиум-класса в центре у набережной Исети, видовая; ppm² 444k > 350k = премиум.'), + (8264, 'элит', true, 'medium', 'ppm² 262k (элит-диапазон), 11/13 лотов ≥20М; desc — ЖК На Некрасова, арт-деко, дизайн Vision Interieure, Derufa premium → дизайнерский премиум-проект.'), + (10878, 'бизнес', true, 'medium', 'ppm² 247k (бизнес/элит граница), 11/14 ≥20М; desc — кирпичный спецпроект, 18 этажей, просторные планировки → бизнес-класс.'), + (6843, 'бизнес', true, 'high', 'desc прямо — ЖК бизнес-класса «Отдельный, 5» у УРФУ; ppm² 240k подтверждает бизнес.'), + (372948, 'комфорт', false, 'medium', 'desc — ЖК ЯрСити (массовый комфорт-проект Брусники); ppm² 157k = комфорт, перегрет отдельными лотами с ремонтом «для себя».'), + (372954, 'эконом', false, 'medium', 'ppm² 115k (эконом), n=106 массовая выборка; desc — квартира-студия у Пл. Маркса, типовой массмаркет, 11/106 дорогих лотов случайны.'), + (9860, 'премиум', true, 'high', 'ppm² 552k — premium-уровень (>350k), все 10/10 лотов ≥20М; центр, Первомайская 7 → клубный/премиум дом (desc нет, но ppm² однозначен).'), + (12116, 'элит', true, 'high', 'ppm² 291k (элит); desc — ЖК «Мечта» бизнес-класс в центре, дизайнерский ремонт, престижный → премиум.'), + (8248, 'бизнес', true, 'high', 'desc — ЖК бизнес-класса «Отдельный, 5» у УРФУ (тот же объект, что hid 6843); ppm² 253k подтверждает.'), + (8065, 'бизнес', true, 'high', 'desc — ЖК «Clever Park» (Брусника), панорамные окна, вид на набережную Исети, новый дом; ppm² 251k = бизнес-класс.'), + (7041, 'бизнес', true, 'low', 'ppm² 228k (бизнес-уровень), закрытая территория, подземный паркинг, охрана; desc продавца называет «комфорт класса» — противоречие, но медианный ppm² тянет к бизнесу.'), + (9878, 'элит', true, 'high', 'desc: Клубный дом «Тихвинъ» — родоначальник элитного сегмента ЕКБ; ppm² 472k (премиум-уровень), 9/9 elite.'), + (9754, 'премиум', true, 'medium', 'ppm² 434k (>350k премиум), центр ЕКБ, 9/9 elite; no desc but price level unambiguous.'), + (12703, 'бизнес', true, 'high', 'desc явно «бизнес-класса ЖК Клевер парк»; ppm² 305k, новый центр ЕКБ.'), + (373331, 'комфорт', false, 'medium', 'desc: ЖК «Парковый квартал 2.0 — комфорт-класс»; ppm² 212k inflated by few lots (9/42 elite), mass-market.'), + (12208, 'бизнес', true, 'medium', 'desc: «статусный камерный дом», престижный район, видовая, огороженная территория; ppm² 206k = бизнес.'), + (6669, 'комфорт', false, 'medium', 'desc: квартал «Лайв» (Брусника LIVE) — комфорт-класс; only 9/73 elite, premium by concentration not reality.'), + (12596, 'комфорт', false, 'low', 'desc: ЖК «Северные Кварталы» от Брусники, урбан-вилла формат — комфорт/комфорт+; ppm² 204k, 9/28 elite.'), + (372927, 'комфорт', false, 'medium', 'desc: ЖК «Домино» комфорт+ / ЖК «Гулливер»; ppm² 121k = комфорт baseline, mass-market.'), + (372930, 'комфорт', false, 'medium', 'desc: ЖК «Октябрьский» — и упоминается Тюмень (не ЕКБ); ppm² 114k эконом-комфорт, предчистовая отделка.'), + (9750, 'бизнес', true, 'high', 'desc: ЖК «Макаровский» (флагман набережной ЕКБ); ppm² 425k, потолки 3.2м, 8/10 elite.'), + (6747, 'бизнес', true, 'high', 'desc: ЖК «Архитектон», прямо «Бизнес класс»; ppm² 233k (бизнес-диапазон).'), + (6896, 'бизнес', true, 'medium', 'desc: квартал «Тихий центр» (Brusnika) «тихая роскошь в сердце ЕКБ»; ppm² 227k = бизнес.'), + (6930, 'комфорт', false, 'high', 'desc прямо: дом комфорт-класса «Чемпион-парк»; ppm² 200k завышен отдельными видовыми лотами, класс comfort.'), + (375422, 'эконом', false, 'medium', 'desc: квартира-студия от хозяина, «Черемушки 4» застройщик Шэлдом от 5.8М; ppm² 113k = эконом.'), + (8158, 'элит', true, 'high', 'desc: «резиденция в ЖК Макаровский Элит», премиум-класс; ppm² 700k — топ элит/премиум.'), + (8161, 'премиум', true, 'high', 'desc: ЖК «Макаровский» бизнес-класс на набережной; ppm² 428k = премиум-уровень.'), + (9522, 'премиум', true, 'medium', 'descs null, но ppm² 413k (премиум >350k) при n_elite 7/8 — устойчиво премиальный дом в центре.'), + (12316, 'премиум', true, 'medium', 'descs null; ppm² 370k премиум; Народной Воли (наб. Исети) — зона клубных домов (Most).'), + (7189, 'премиум', true, 'high', 'desc: «Клубный Дом Most премиум класса», резиденция, видовая на набережную Исети; ppm² 370k.'), + (12700, 'бизнес', true, 'high', 'desc: ЖК бизнес-класса «Клевер Парк», видовая, дизайнерская, у парка Маяковского; ppm² 306k.'), + (12746, 'элит', true, 'high', 'desc: «самый статусный и узнаваемый дом города», ЖК «Февральская Революция», набережная, однопод., охрана; ppm² 286k.'), + (12547, 'бизнес', true, 'high', 'desc: ЖК «Екатерининский парк», дом бизнес-класса (куратор РМК); ppm² 267k.'), + (12678, 'бизнес', true, 'high', 'desc: ЖК «Вивальди» (назван премиум-классом), центр, кирпич 2017; ppm² 267k = бизнес.'), + (10880, 'бизнес', true, 'high', 'desc: ЖК «Никольский» — «жизнь бизнес-класса в центре», набережная Исети; ppm² 265k.'), + (9528, 'бизнес', true, 'medium', 'тот же адрес Тверитина 46 = ЖК «Вивальди» (см. hid 12678); descs null, ppm² 258k подтверждает бизнес.'), + (8066, 'бизнес', true, 'medium', 'descs null; ppm² 218k попадает в бизнес-диапазон (160-250k) для ЕКБ; нет class-words для усиления.'), + (376817, 'комфорт', false, 'low', 'numeric addr без улицы; descs смешаны (Сочи/Адлер апартаменты, ост. Юбилейный) — масс-маркет/чужой город, ppm² 169k раздут концентрацией дорогих лотов.'), + (376374, 'эконом', false, 'low', 'numeric addr; descs — студия 26м² и 1к в Краснодаре (чужой город), ppm² 125k = эконом/комфорт, явно масс-маркет.'), + (11054, 'элит', true, 'high', 'desc: клубный дом «Ривьера» (заявлен бизнес-класс), центр, авторская отделка; ppm² 640k = премиум-уровень цены.') +ON CONFLICT (house_id) DO UPDATE SET + tier = EXCLUDED.tier, + is_premium = EXCLUDED.is_premium, + confidence = EXCLUDED.confidence, + note = EXCLUDED.note; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_estimator_premium_building.py b/tradein-mvp/backend/tests/test_estimator_premium_building.py index f02e5f9e..b8ceb365 100644 --- a/tradein-mvp/backend/tests/test_estimator_premium_building.py +++ b/tradein-mvp/backend/tests/test_estimator_premium_building.py @@ -1,10 +1,18 @@ -"""#2002: premium_building флаг из MV `premium_houses`. +"""#2002: premium_building флаг + класс из curated overlay → MV `premium_houses`. -Покрывает read-only helper `_is_premium_building`: - - target резолвится в премиальный дом → (True, med_ppm2); - - target не в MV → (False, None); - - house_id is None → (False, None) без обращения к БД; - - MV отсутствует / SQL-ошибка → graceful (False, None), оценка не падает. +Покрывает read-only helper `_is_premium_building`, который возвращает 3-кортеж +`(is_premium, med_ppm2, building_class)` из двухслойного overlay: + 1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой (класс + + is_premium с false-positive demotions); ПЕРЕБИВАЕТ MV; + 2. `premium_houses` MV (data/sql/139) — даёт med_ppm2 + fallback-членство. + +Сценарии: + - curated premium=True с классом → (True, MV.med_ppm2, tier); + - curated premium=False демотит дом из MV → (False, med, tier) — false-positive; + - невыверенный дом в MV → (True, med, None) — членство есть, класс неизвестен; + - дом нигде → (False, None, None); + - house_id is None → (False, None, None) без обращения к БД; + - таблица отсутствует / SQL-ошибка → graceful (False, None, None), оценка не падает. Плюс smoke-проверка дефолтов в response-модели AggregatedEstimate (флаг — метаданные, сериализация без изменений, когда дом не премиальный). @@ -23,65 +31,117 @@ import pytest from app.services.estimator import _is_premium_building -def _make_mock_db(*, first_row: Any, raises: bool = False) -> MagicMock: - """mock db.execute(...).first() → first_row; raises=True имитирует отсутствие MV.""" +def _make_mock_db( + *, curated_row: Any = None, mv_row: Any = None, raises: bool = False +) -> MagicMock: + """mock db.execute, маршрутизируя по SQL-тексту. + + `SELECT ... FROM premium_buildings_curated ...` → curated_row; + `SELECT med_ppm2 FROM premium_houses ...` → mv_row. + raises=True имитирует отсутствие таблицы (первый же execute бросает). + """ mock_db = MagicMock() if raises: - mock_db.execute.side_effect = RuntimeError('relation "premium_houses" does not exist') - else: - mock_db.execute.return_value.first.return_value = first_row + mock_db.execute.side_effect = RuntimeError( + 'relation "premium_buildings_curated" does not exist' + ) + return mock_db + + def _execute(sql: Any, params: Any = None) -> MagicMock: + text_sql = str(getattr(sql, "text", sql)) + result = MagicMock() + if "premium_buildings_curated" in text_sql: + result.first.return_value = curated_row + elif "premium_houses" in text_sql: + result.first.return_value = mv_row + else: # pragma: no cover — defensive + result.first.return_value = None + return result + + mock_db.execute.side_effect = _execute return mock_db -def test_premium_house_yields_true_with_median() -> None: - """house_id в premium_houses → (True, med_ppm2).""" - db = _make_mock_db(first_row=(255_000,)) - - is_premium, median = _is_premium_building(db, 4242) - - assert is_premium is True - assert median == 255_000 - # CAST(:hid AS bigint) — psycopg v3, не :hid::bigint - sql = str(db.execute.call_args.args[0].text) - assert "CAST(:hid AS bigint)" in sql - assert "::bigint" not in sql +def _executed_sql(db: MagicMock) -> list[str]: + """Тексты всех SQL-запросов, прошедших через db.execute.""" + return [str(call.args[0].text) for call in db.execute.call_args_list] -def test_non_premium_house_yields_false_none() -> None: - """house_id отсутствует в MV (first() → None) → (False, None).""" - db = _make_mock_db(first_row=None) +def test_curated_premium_with_class_overrides_mv() -> None: + """curated is_premium=True + tier → (True, MV.med_ppm2, tier); класс из curated.""" + db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(422_000,)) - assert _is_premium_building(db, 9999) == (False, None) + assert _is_premium_building(db, 11370) == (True, 422_000, "премиум") + # обе таблицы используют CAST(:hid AS bigint) — psycopg v3, не :hid::bigint + sqls = _executed_sql(db) + assert any("premium_buildings_curated" in s for s in sqls) + assert any("premium_houses" in s for s in sqls) + for s in sqls: + assert "CAST(:hid AS bigint)" in s + assert "::bigint" not in s + + +def test_curated_false_positive_demotes_mv_member() -> None: + """curated is_premium=False демотит дом-член MV → (False, med, tier). + + Концентрационный MV пометил дом премиальным, но curation признал его + false-positive (mass-market). curated перебивает — флаг False, но med и класс + из данных сохраняются как контекст для ревьюера. + """ + db = _make_mock_db(curated_row=(False, "комфорт"), mv_row=(211_000,)) + + assert _is_premium_building(db, 9401) == (False, 211_000, "комфорт") + + +def test_curated_class_flows_when_mv_absent() -> None: + """curated есть, дома нет в MV → (curated.is_premium, None, tier).""" + db = _make_mock_db(curated_row=(True, "бизнес"), mv_row=None) + + assert _is_premium_building(db, 6685) == (True, None, "бизнес") + + +def test_uncurated_mv_member_premium_true_class_none() -> None: + """Невыверенный дом, но член MV → (True, med, None) — класс неизвестен.""" + db = _make_mock_db(curated_row=None, mv_row=(255_000,)) + + assert _is_premium_building(db, 4242) == (True, 255_000, None) + + +def test_not_premium_anywhere_yields_false_none_none() -> None: + """Дом отсутствует и в curated, и в MV → (False, None, None).""" + db = _make_mock_db(curated_row=None, mv_row=None) + + assert _is_premium_building(db, 9999) == (False, None, None) def test_house_id_none_short_circuits_without_query() -> None: - """house_id is None → (False, None), к БД не обращаемся.""" - db = _make_mock_db(first_row=(255_000,)) + """house_id is None → (False, None, None), к БД не обращаемся.""" + db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(255_000,)) - assert _is_premium_building(db, None) == (False, None) + assert _is_premium_building(db, None) == (False, None, None) db.execute.assert_not_called() -def test_missing_mv_degrades_gracefully() -> None: - """MV не существует / SQL-ошибка → (False, None), без проброса исключения.""" - db = _make_mock_db(first_row=None, raises=True) +def test_missing_table_degrades_gracefully() -> None: + """Таблица не существует / SQL-ошибка → (False, None, None), без проброса.""" + db = _make_mock_db(raises=True) - assert _is_premium_building(db, 4242) == (False, None) + assert _is_premium_building(db, 4242) == (False, None, None) # после ошибки откатываем poisoned-транзакцию db.rollback.assert_called_once() -def test_premium_median_null_in_mv_is_handled() -> None: - """Дом в MV, но med_ppm2 NULL → (True, None) (премиум-флаг есть, медианы нет).""" - db = _make_mock_db(first_row=(None,)) +def test_mv_median_null_is_handled() -> None: + """Дом в MV, но med_ppm2 NULL → median None (флаг/класс не теряются).""" + db = _make_mock_db(curated_row=(True, "элит"), mv_row=(None,)) - assert _is_premium_building(db, 4242) == (True, None) + assert _is_premium_building(db, 7109) == (True, None, "элит") def test_response_model_defaults_non_premium() -> None: - """AggregatedEstimate по умолчанию: premium_building=False, median=None. + """AggregatedEstimate по умолчанию: premium_building=False, median/class=None. - Гарантирует, что добавление флага не меняет сериализацию для не-премиум оценок. + Гарантирует, что добавление полей не меняет сериализацию для не-премиум оценок. """ from datetime import UTC, datetime from uuid import uuid4 @@ -104,9 +164,38 @@ def test_response_model_defaults_non_premium() -> None: assert est.premium_building is False assert est.premium_building_median_ppm2 is None + assert est.premium_building_class is None dumped = est.model_dump() assert dumped["premium_building"] is False assert dumped["premium_building_median_ppm2"] is None + assert dumped["premium_building_class"] is None + + +def test_response_model_carries_curated_class() -> None: + """premium_building_class сериализуется, когда задан (curated overlay).""" + from datetime import UTC, datetime + from uuid import uuid4 + + from app.schemas.trade_in import AggregatedEstimate + + est = AggregatedEstimate( + estimate_id=uuid4(), + median_price_rub=42_000_000, + range_low_rub=40_000_000, + range_high_rub=44_000_000, + median_price_per_m2=420_000, + confidence="medium", + n_analogs=8, + period_months=24, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC), + premium_building=True, + premium_building_median_ppm2=422_000, + premium_building_class="премиум", + ) + + assert est.model_dump()["premium_building_class"] == "премиум" if __name__ == "__main__": # pragma: no cover -- 2.45.3