gendesign/backend/app/api/v1/concepts.py
Light1YT 74f1ffb500
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m49s
CI / backend-tests (pull_request) Successful in 13m58s
perf(objective): request-path consumers dedup inline, not via whole-table view (#1964)
deep-review #1964: v_objective_lots_latest has NO premise/district filter inside,
so a consumer's outer WHERE cannot push below DISTINCT ON → the view materializes
the WHOLE table (Parallel Seq Scan + external Sort 1.76M rows, ~55MB spill) on
every query. For REQUEST-PATH consumers inside analyze_parcel this is a ~19x latency
regression vs the pre-#1964 raw-table plan.

DISPROVEN remedy (NOT applied): a full index on the physflat-key does NOT help —
DISTINCT ON selects ol.* (51 cols, width≈945) so index-only-unique is impossible;
the planner ignores the index (seq-scan+sort still cheaper) and even forced it is
~3.9 s. A 142MB index for zero request-path benefit + slower bulk-INSERT during
objective-scrape is wrong. Honors original #1964 decision "no new index".

Prod EXPLAIN (Академический / 3km radius, 2026-06-28):
  consumer            via view      inline (this commit)
  concepts median     5854 ms       1640 ms   (bitmap district + sort)
  parcels district    5854 ms       1640 ms
  parcels geo-median  6443 ms        122 ms   (NestedLoop geo->complex bitmap)
  parcels obj_pricing 5721 ms        441 ms   (project bitmap per nearby ЖК)

FIX: keep v_objective_lots_latest ONLY for batch/background/cached consumers
(supply_layers L1, competitors._SOLD_COUNT_SQL, special_indices [/forecast bg task
30-180s], admin, landing). Revert the 4 request-path consumers inside analyze_parcel
to inline DISTINCT ON (physflat-key, latest snapshot) with the filter pushed INTO the
CTE so the district/spatial/project index applies:
- concepts._OBJECTIVE_MEDIAN_SQL
- parcels.py district price block
- parcels.py geo-radius median (complex_id-scoped)
- parcels.py obj_pricing CTE (project_name-scoped; aggregates over deduped set)

Migration 175 header CORRECTED: accurately states the partial mig-173 index does NOT
serve the view (qual can't push below DISTINCT ON), the full index is disproven/not
added, and which consumers use the view vs inline. No DDL change (still view-only).

Tests: +guards (concepts/obj_pricing dedup inline, not view; obj_pricing physflat
DISTINCT ON; perf-pushdown scope preserved). 965 passed.
2026-06-28 05:00:47 +05:00

357 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from shapely.geometry import Point
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.concept import (
ConceptInput,
ConceptOutput,
HouseTypeCatalog,
HouseTypeCatalogItem,
MassingProgram,
MassingRecomputeOutput,
)
from app.services.generative import geometry
from app.services.generative.catalog import HOUSE_TYPES, available_section_types
from app.services.generative.financial import compute_financial
from app.services.generative.geometry import ParcelGeometryError, _parse_polygon
from app.services.generative.teap import synthesize_teap_from_program
logger = logging.getLogger(__name__)
router = APIRouter()
# Минимум объявлений Objective в районе для надёжной медианы цены продажи жилья.
# n>=10: с меньшей выборкой медиана шумна (1-2 нетипичных лота её перекашивают) и
# выдавать её за «рыночную цену района» нечестно. При n<10 честнее откатиться на
# справочную median_price_per_m2 района (ekb_districts, 24-мес окно), а при её
# отсутствии — на норматив класса. Порог зеркалит «осторожную выборку» из других
# district-агрегатов проекта (см. parcels.py district_price_block).
_MIN_OBJECTIVE_SAMPLE: int = 10
# Радиус поиска района от центроида участка (м). Зеркалит ST_DWithin 5 км из
# parcels.py district-context — участок может лежать у границы полигона района.
_DISTRICT_SEARCH_RADIUS_M: int = 5000
# Санитарные границы цены продажи, руб/кв.м — отсекают мусорные лоты Objective
# (price_per_m2_rub — это РУБЛИ, не тысячи). Те же пороги, что в parcels.py.
_PRICE_MIN_RUB: int = 30_000
_PRICE_MAX_RUB: int = 600_000
# Stage 2a (#1965): дефолтный лейбл source для ПРЕДРЕЗОЛВЛЕННОЙ цены в /recompute, когда
# фронт НЕ форвардит подлинный price_source. Консервативный fallback: цена пришла извне
# (фронт её уже резолвил), но без явного происхождения берём нейтральный district-median.
_DEFAULT_PRERESOLVED_SOURCE: str = "objective_district_median"
# Район ЕКБ (ekb_districts) ближайший к точке центроида + его справочная медиана цены.
# Binds через CAST(:name AS type) — psycopg v3 (постфикс-каст к bind-имени запрещён).
# ::geography приклеено к ) / колонке (разрешённое исключение). Read-only, без SAVEPOINT.
_DISTRICT_FOR_POINT_SQL = text(
"""
SELECT d.district_name,
d.median_price_per_m2
FROM ekb_districts d
WHERE d.geom IS NOT NULL
AND ST_DWithin(
d.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography,
CAST(:radius AS double precision)
)
ORDER BY ST_Distance(
d.geom::geography,
ST_GeomFromText(CAST(:wkt AS text), 4326)::geography
) ASC
LIMIT 1
"""
)
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
#
# #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
# один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
_OBJECTIVE_MEDIAN_SQL = text(
"""
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
price_per_m2_rub
FROM objective_lots
WHERE district = CAST(:dn AS text)
ORDER BY project_name, corpus_name, section, floor, lot_number,
snapshot_date DESC, id DESC
)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size
FROM latest
WHERE price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
"""
)
def _parcel_centroid_wkt(payload: ConceptInput) -> str:
"""Центроид полигона участка как WKT-точка (WGS84) для spatial-lookup."""
polygon = _parse_polygon(payload.parcel_geojson)
c = polygon.centroid
wkt: str = Point(c.x, c.y).wkt
return wkt
def _lookup_market_price(db: Session, wkt_point: str) -> tuple[float | None, str]:
"""Рыночная цена продажи жилья, руб/кв.м, для участка по его центроиду + источник.
Алгоритм (от точного к общему):
1. Центроид → ближайший район ЕКБ (ekb_districts, ST_DWithin 5 км).
2. Медиана Objective по этому району (price_per_m2_rub, n>=:_MIN_OBJECTIVE_SAMPLE)
→ ("objective_district_median"). Свежайший рыночный сигнал.
3. Иначе справочная ekb_districts.median_price_per_m2 → ("district_reference").
4. Иначе (нет района / нет данных) → (None, "class_norm") — финмодель возьмёт
норматив класса.
Никогда не роняет генерацию концепции: ЛЮБАЯ ошибка (вне ЕКБ, нет PostGIS, сбой
БД, нет таблицы) логируется warning'ом и деградирует в (None, "class_norm").
Read-only — SAVEPOINT не нужен.
"""
try:
district_row = (
db.execute(
_DISTRICT_FOR_POINT_SQL,
{"wkt": wkt_point, "radius": _DISTRICT_SEARCH_RADIUS_M},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning("concept market-price: district lookup failed, fallback class_norm: %s", exc)
return None, "class_norm"
if not district_row or not district_row["district_name"]:
logger.info("concept market-price: parcel outside ЕКБ districts → class_norm")
return None, "class_norm"
district_name: str = district_row["district_name"]
district_reference = district_row["median_price_per_m2"]
# 2) Свежайший сигнал — медиана объявлений Objective по району (n>=порог).
try:
obj_row = (
db.execute(
_OBJECTIVE_MEDIAN_SQL,
{"dn": district_name, "lo": _PRICE_MIN_RUB, "hi": _PRICE_MAX_RUB},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning(
"concept market-price: objective median failed (district=%s), "
"trying district reference: %s",
district_name,
exc,
)
obj_row = None
if (
obj_row
and obj_row["sample_size"]
and int(obj_row["sample_size"]) >= _MIN_OBJECTIVE_SAMPLE
and obj_row["median_ppm2"]
):
price = float(obj_row["median_ppm2"])
logger.info(
"concept market-price: objective median %.0f руб/м² (district=%s, n=%d)",
price,
district_name,
int(obj_row["sample_size"]),
)
return price, "objective_district_median"
# 3) Справочная медиана района (24-мес окно ekb_districts).
if district_reference is not None:
price = float(district_reference)
logger.info(
"concept market-price: district reference %.0f руб/м² (district=%s, n<%d)",
price,
district_name,
_MIN_OBJECTIVE_SAMPLE,
)
return price, "district_reference"
logger.info(
"concept market-price: district=%s has no price reference → class_norm", district_name
)
return None, "class_norm"
@router.get("/house-types", response_model=HouseTypeCatalog)
async def list_house_types() -> HouseTypeCatalog:
"""Stage 3b (#1965, эпик #1953) — каталог типовых домов для фронт-пикера.
Read-only проекция ``catalog.HOUSE_TYPES`` (single source of truth по типам
секций): фронт Stage 3b показывает эти типы в режиме «Выбрать дома» и кладёт
выбранные ``section_type`` в ``ConceptInput.building_program``. Без БД, без
side-effects — справочник захардкожен в коде (см. модуль ``catalog``). Так
фронт НЕ хардкодит габариты/этажности — они приходят отсюда.
"""
return HouseTypeCatalog(
house_types=[
HouseTypeCatalogItem(
section_type=ht.section_type,
label_ru=ht.label_ru,
footprint_w_m=ht.footprint_w_m,
footprint_d_m=ht.footprint_d_m,
footprint_sqm=ht.footprint_sqm,
default_floors=ht.default_floors,
housing_class=ht.housing_class,
)
for ht in HOUSE_TYPES
]
)
@router.post("", response_model=ConceptOutput)
async def create_concept(
payload: ConceptInput,
db: Annotated[Session, Depends(get_db)],
) -> ConceptOutput:
"""Generate 3 building variants for the given parcel polygon.
Stage 1a: Shapely parse + buildable area (setback) + placement grid.
Stage 1b: greedy section placement with STRtree collisions (3 strategies).
Stage 1c: real ТЭП + financial model attached to each variant. The housing
sale price is calibrated to real market data (Objective / district reference)
when available, falling back to the per-class norm with an honest source flag
(PR-2, эпик #1881).
A degenerate parcel (setback consumes everything, malformed geometry) yields a
422 rather than empty variants — that is a bad request, not a valid empty result.
Stage 3a (#1965): если задана ``building_program`` (типовые дома из каталога), кладём
РОВНО эту программу вместо жадной max-FAR раскладки (один вариант). Неизвестный
``section_type`` → 422 (валидируется тут, до размещения). Если участок не вмещает всю
программу — НЕ 422: кладём сколько влезло и отдаём честный ``placed_count`` < requested.
"""
# Stage 3a: валидируем ключи программы по каталогу ДО размещения — неизвестный
# section_type это bad request (422), а не 500 из KeyError в глубине placement.
if payload.building_program:
known = available_section_types()
unknown = sorted({item.section_type for item in payload.building_program} - known)
if unknown:
raise HTTPException(
status_code=422,
detail=(
f"unknown house type(s): {', '.join(unknown)}; "
f"available: {', '.join(sorted(known))}"
),
)
# Рыночную цену продажи жилья считаем ОДИН раз на участок (она едина для всех
# стратегий). DB-lookup — синхронный SQLAlchemy → run_in_threadpool, чтобы не
# блокировать event loop. Падение lookup'а не должно ронять генерацию.
market_price: float | None = None
price_source: str = "class_norm"
try:
wkt_point = await run_in_threadpool(_parcel_centroid_wkt, payload)
market_price, price_source = await run_in_threadpool(_lookup_market_price, db, wkt_point)
except ParcelGeometryError:
# Невалидная геометрия — пусть geometry.generate поднимет её ниже (один 422).
pass
except Exception as exc:
# Защитный пояс: любая иная ошибка lookup'а → норматив класса, не 500.
logger.warning("concept market-price: unexpected lookup error, class_norm: %s", exc)
try:
# geometry.generate — синхронный CPU-bound (Shapely/STRtree), мостим через
# run_in_threadpool, чтобы НЕ блокировать event loop (тот же приём, что и в chat.py).
variants = await run_in_threadpool(
geometry.generate,
payload,
market_price_per_sqm=market_price,
price_source=price_source,
)
except ParcelGeometryError as exc:
logger.warning("concept generation rejected parcel: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
return ConceptOutput(variants=variants)
@router.post("/recompute", response_model=MassingRecomputeOutput)
async def recompute_massing(
payload: MassingProgram,
db: Annotated[Session, Depends(get_db)],
) -> MassingRecomputeOutput:
"""Stage 2a (#1965, эпик #1953) — LIVE-пересчёт экономики из 3D-массинга.
Hot, debounced-эндпоинт для интерактивных слайдеров Stage 2b: фронтовый
``computeModel`` отдаёт агрегированную программу (суммарное пятно × этажность),
мы синтезируем :class:`TEAP` (``synthesize_teap_from_program`` — те же нормативные
константы, что и обычная генерация) и прогоняем готовый чистый ``compute_financial``
→ пересчитанный ТЭП + финмодель.
Цена продажи жилья:
* ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) —
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе. Источник
берём из ``payload.price_source`` (ПОДЛИННЫЙ source, который фронт получил из
financial_estimate — objective_geo_radius / objective_district_median /
district_reference / class_norm); если фронт его не прислал — дефолтный лейбл.
НЕ хардкодим source — иначе honest-флаг в UI/PDF врёт о происхождении цены.
* иначе, если передан ``parcel_centroid_wkt`` → ``_lookup_market_price`` по центроиду
через ``run_in_threadpool`` (тот же приём, что и в ``create_concept``; sync SQLAlchemy
не должен блокировать event loop). Падение lookup'а → норматив класса, не 500.
* иначе (нет ни цены, ни центроида) → норматив класса (``price_source="class_norm"``).
"""
# ── Цена продажи: предрезолвленная (fast) → DB-fallback → норматив класса ───────
market_price: float | None = payload.market_price_per_sqm
price_source: str = "class_norm"
if market_price is not None:
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
# Используем ПОДЛИННЫЙ source, который фронт форвардит из financial_estimate; если
# его нет — дефолтный лейбл. (При market_price=None compute_financial всё равно
# форсит class_norm, но сюда мы попадаем только при заданной цене.)
price_source = payload.price_source or _DEFAULT_PRERESOLVED_SOURCE
elif payload.parcel_centroid_wkt:
# Fallback: резолвим по центроиду. Sync-lookup → threadpool (как в create_concept).
# Любая ошибка → норматив класса (graceful, без 500) — деградация уже внутри
# _lookup_market_price, защитный пояс на непредвиденное оставляем тут.
try:
market_price, price_source = await run_in_threadpool(
_lookup_market_price, db, payload.parcel_centroid_wkt
)
except Exception as exc:
logger.warning("recompute market-price: unexpected lookup error, class_norm: %s", exc)
market_price, price_source = None, "class_norm"
teap = synthesize_teap_from_program(
total_footprint_sqm=payload.total_footprint_sqm,
floors=payload.floors,
site_area_sqm=payload.site_area_sqm,
housing_class=payload.housing_class,
sections=payload.sections,
)
# compute_financial — чистая, CPU-bound, лёгкая (без БД) → можно вызывать прямо в
# event loop (hot path; threadpool-overhead тут не нужен). price_source игнорируется
# внутри, когда market_price_per_sqm is None (форсится class_norm) — корректно.
financial = compute_financial(
teap=teap,
housing_class=payload.housing_class,
land_cost_rub=payload.land_cost_rub,
market_price_per_sqm=market_price,
price_source=price_source,
development_type=payload.development_type,
)
return MassingRecomputeOutput(teap=teap, financial=financial)