fix(generative): forward genuine price_source through /recompute (#1965 Stage 2a)
Code-review follow-up: /recompute hardcoded price_source="objective_district_median" for any body-supplied market_price_per_sqm, mislabeling the honesty-flag once Stage 2b forwards a price whose genuine source differs (objective_geo_radius / district_reference / class_norm from financial_estimate). - schemas/concept.py: add optional price_source: str | None to MassingProgram. - api/v1/concepts.py: on FAST path use payload.price_source if provided, else the default label (now a module-level constant _DEFAULT_PRERESOLVED_SOURCE). DB-fallback and class-norm paths keep their own resolved source unchanged. - tests: assert body-provided price_source echoes through to financial.price_source (not overwritten), and the default label applies when the front omits it.
This commit is contained in:
parent
d38d5d43c5
commit
18d012da1b
3 changed files with 48 additions and 2 deletions
|
|
@ -40,6 +40,11 @@ _DISTRICT_SEARCH_RADIUS_M: int = 5000
|
|||
_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.
|
||||
|
|
@ -234,7 +239,11 @@ async def recompute_massing(
|
|||
|
||||
Цена продажи жилья:
|
||||
* ``market_price_per_sqm`` в теле → используется как есть (FAST path, БЕЗ БД) —
|
||||
фронт резолвит цену один раз и шлёт её в каждом keystroke-запросе.
|
||||
фронт резолвит цену один раз и шлёт её в каждом 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.
|
||||
|
|
@ -245,7 +254,10 @@ async def recompute_massing(
|
|||
price_source: str = "class_norm"
|
||||
if market_price is not None:
|
||||
# FAST path: фронт уже резолвил рыночную цену — НЕ ходим в БД на каждый keystroke.
|
||||
price_source = "objective_district_median"
|
||||
# Используем ПОДЛИННЫЙ 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) — деградация уже внутри
|
||||
|
|
|
|||
|
|
@ -183,6 +183,13 @@ class MassingProgram(BaseModel):
|
|||
market_price_per_sqm: float | None = Field(
|
||||
None, ge=0, description="Предрезолвленная рыночная цена жилья, руб/кв.м (skip DB lookup)"
|
||||
)
|
||||
# ПОДЛИННЫЙ источник предрезолвленной цены, который фронт получил из financial_estimate
|
||||
# (objective_geo_radius / objective_district_median / district_reference / class_norm).
|
||||
# Honest-флаг для UI/PDF: НЕ подменяем его захардкоженным лейблом. Применяется ТОЛЬКО
|
||||
# вместе с market_price_per_sqm; None → дефолтный лейбл (см. эндпоинт recompute_massing).
|
||||
price_source: str | None = Field(
|
||||
None, description="Подлинный источник предрезолвленной цены (forward из financial_estimate)"
|
||||
)
|
||||
# Центроид участка (WKT-точка WGS84) для DB-fallback цены, когда market_price_per_sqm
|
||||
# не передан. None → пропускаем lookup и берём норматив класса (нет геопривязки).
|
||||
parcel_centroid_wkt: str | None = Field(
|
||||
|
|
|
|||
|
|
@ -69,6 +69,33 @@ def test_recompute_market_price_passthrough_skips_db() -> None:
|
|||
# Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена.
|
||||
assert fin["price_per_sqm_used"] == 180_000.0
|
||||
assert fin["price_is_calibrated"] is True
|
||||
# Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source).
|
||||
assert fin["price_source"] == "objective_district_median"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
def test_recompute_body_price_source_echoed_through() -> None:
|
||||
# Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) —
|
||||
# эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF.
|
||||
# get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД.
|
||||
class _ExplodingSession:
|
||||
def execute(self, *_a: object, **_k: object) -> None:
|
||||
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
|
||||
|
||||
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/concepts/recompute",
|
||||
json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
fin = resp.json()["financial"]
|
||||
assert fin["price_per_sqm_used"] == 160_000.0
|
||||
assert fin["price_is_calibrated"] is True
|
||||
# Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median".
|
||||
assert fin["price_source"] == "objective_geo_radius"
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue