feat(tradein): AI-curated premium-building overlay — class + false-positive demotion (#2002)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m29s
Deploy Trade-In / deploy (push) Successful in 52s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s

This commit is contained in:
lekss361 2026-06-27 18:43:05 +00:00
parent 6aff5a000d
commit e39fb86a9b
4 changed files with 314 additions and 62 deletions

View file

@ -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 — оценку НЕ стоит авто-оффэрить, нужна ручная
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /

View file

@ -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(

View file

@ -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;

View file

@ -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