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
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:
parent
a9e9707018
commit
37bf3d0e93
2 changed files with 166 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
147
backend/tests/api/v1/test_2464_confidence_zoning_source.py
Normal file
147
backend/tests/api/v1/test_2464_confidence_zoning_source.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue