fix(ptica): достоверность видит зону ПЗЗ из НСПД, а не только из пустой таблицы (#2464) (#2933)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 1m56s
Deploy / build-worker (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m54s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s

This commit is contained in:
bot-backend 2026-08-19 13:29:25 +00:00
parent a9e9707018
commit 37bf3d0e93
2 changed files with 166 additions and 0 deletions

View file

@ -1089,6 +1089,7 @@ def _compute_confidence(
weather: dict[str, Any] | None,
market_trend: dict[str, Any] | None,
zoning: dict[str, Any],
nspd_zoning: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze.
@ -1177,7 +1178,24 @@ def _compute_confidence(
caveats.append("Air Quality API недоступен — exposure unknown")
# 7) ПЗЗ coverage — placeholder до G1
# Зона ПЗЗ приходит ДВУМЯ путями, и признак обязан учитывать оба.
#
# `zoning` — старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она
# ПУСТА (0 строк, замер 19.08), поэтому `data_available` там всегда False.
# Настоящая зона живёт в `nspd_zoning`: из территориальных зон дампа НСПД, а
# для участков в зазорах между зонами — синтезируется резолвером геопортала
# (см. комментарий PR-A #financial-zoning-decouple выше по файлу).
#
# Пока сюда передавали только `zoning`, подскор был 0.2 у КАЖДОГО участка, а
# оговорка ниже утверждала неправду. Прогон analyze на проде, участок
# 66:41:0402029:25: `nspd_zoning.zone_code = 'Ж-5'`, при этом
# `confidence = 0.61` и оговорка «ПЗЗ zone_code не известен». Отчёт в одном и
# том же ответе показывал зону и заявлял, что зона неизвестна. Подскоров семь,
# значит цена ошибки в композите — (1.0 0.2) / 7 = 0.114: 0.61 вместо 0.72.
_nspd = nspd_zoning or {}
has_zoning = bool(zoning.get("data_available")) if zoning else False
if not has_zoning:
has_zoning = bool(_nspd.get("zone_code") or _nspd.get("regulation_zone_index"))
subscores["zoning"] = 1.0 if has_zoning else 0.2
if not has_zoning:
caveats.append(
@ -3854,6 +3872,7 @@ def analyze_parcel(
weather=weather,
market_trend=market_trend,
zoning=zoning,
nspd_zoning=nspd_dump_data.get("nspd_zoning"),
)
# D4 (#36): aggregate pipeline_24mo

View file

@ -0,0 +1,147 @@
"""#2464: достоверность должна видеть зону ПЗЗ из ОБОИХ источников.
Зона приходит двумя путями:
* `zoning` старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она
**пуста** (0 строк, замер 19.08.2026), поэтому `data_available` там всегда False;
* `nspd_zoning` территориальные зоны дампа НСПД, а для участков в зазорах между
зонами зона синтезируется резолвером геопортала.
Пока в `_compute_confidence` передавали только первый, подскор «zoning» был 0.2 у
КАЖДОГО участка, а оговорка утверждала неправду. Прогон analyze на проде, участок
66:41:0402029:25 (вызов функции в живом контейнере):
nspd_zoning.zone_code = 'Ж-5' <- зона известна
zoning.data_available = False
confidence = 0.61 medium
подскор zoning = 0.2
оговорка: «ПЗЗ zone_code не известен нельзя оценить разрешённое использование»
То есть один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна.
Подскоров семь, цена ошибки в композите (1.0 0.2) / 7 = 0.114.
"""
from __future__ import annotations
from typing import Any
from app.api.v1.parcels import _compute_confidence
_CAVEAT = "ПЗЗ zone_code не известен"
def _confidence(**over: Any) -> dict[str, Any]:
"""Вызов с общими аргументами.
`nspd_zoning` НЕ передаётся, если его явно не попросили: тесты-контроли обязаны
исполняться и на origin/main, где такого параметра ещё нет. Иначе они падали бы
там с TypeError и контролями не были бы красное «нет такой возможности»
неотличимо от красного «значение неверно».
"""
base: dict[str, Any] = {
"source": "cad_quarter",
"poi_rows": [],
"district_row": None,
"competitor_rows": [],
"noise_map_rows_nearby": 0,
"air_q": None,
"weather": None,
"market_trend": None,
"zoning": {"data_available": False},
}
base.update(over)
return _compute_confidence(**base)
def test_nspd_zone_counts_as_known() -> None:
"""Зона есть только в nspd_zoning → подскор полный, ложной оговорки нет."""
res = _confidence(nspd_zoning={"zone_code": "Ж-5"})
assert res["breakdown"]["zoning"] == 1.0
assert not any(
_CAVEAT in c for c in res["caveats"]
), "оговорка «зона неизвестна» при известной зоне Ж-5 — ровно то, что видел прод"
def test_regulation_zone_index_also_counts() -> None:
"""Синтезированная геопорталом зона несёт regulation_zone_index, а не zone_code."""
res = _confidence(nspd_zoning={"regulation_zone_index": "Ж-2"})
assert res["breakdown"]["zoning"] == 1.0
def test_no_zone_anywhere_keeps_the_caveat() -> None:
"""Контроль: когда зоны нет НИ В ОДНОМ источнике, оговорка остаётся.
Зелёный по обе стороны правки иначе правка просто глушила бы предупреждение.
"""
res = _confidence() # без nspd_zoning — исполняется и на origin/main
assert res["breakdown"]["zoning"] == 0.2
assert any(_CAVEAT in c for c in res["caveats"])
def test_legacy_source_still_honoured() -> None:
"""Контроль: если старый слой когда-нибудь наполнится, он по-прежнему считается."""
res = _confidence(zoning={"data_available": True}) # без nspd_zoning
assert res["breakdown"]["zoning"] == 1.0
def test_composite_gain_matches_the_measured_cost() -> None:
"""Цена дефекта в композите — ровно 0.114, как посчитано по проду."""
without = _confidence()
with_zone = _confidence(nspd_zoning={"zone_code": "Ж-5"})
assert len(without["breakdown"]) == 7, "число подскоров изменилось — пересчитать цену"
assert round(with_zone["value"] - without["value"], 2) == 0.11
# ── поведенческая проверка на уровне ручки + проводка ───────────────────────────
#
# Проверки выше падают на origin/main с TypeError — это «возможности нет», а не
# «значение неверно». Тест ниже красный ПО ПОВЕДЕНИЮ: на origin/main ручка вернёт
# ложную оговорку при известной зоне. Он же сторожит проводку: параметр можно
# добавить в функцию и забыть передать на вызове, и все проверки выше останутся
# зелёными.
def test_analyze_does_not_claim_unknown_zone_when_nspd_resolved_it() -> None:
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
from tests.api.v1.test_analyze_market_price import (
_make_db_for_analyze,
_override_db,
_start_patches,
_stop_patches,
)
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
with patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value={
"nspd_zoning": {"zone_code": "Ж-5", "zone_name": "Многоэтажная жилая"},
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_dump": {"available": True, "stale": False, "harvest_triggered": False},
},
):
resp = TestClient(app).post("/api/v1/parcels/66:41:0204016:10/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
finally:
app.dependency_overrides.clear()
_stop_patches()
assert (body.get("nspd_zoning") or {}).get(
"zone_code"
) == "Ж-5", "предусловие теста не выполнено: зона не доехала до ответа"
caveats = " ".join(body.get("confidence_caveats") or [])
assert (
_CAVEAT not in caveats
), "ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна"
assert (body.get("confidence_breakdown") or {}).get("zoning") == 1.0