From 956b4db010ecfa387583cea2ff9464828d0d9e74 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 18:09:11 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=B4=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=B7=D0=BE=D0=BD=D1=83=20=D0=9F=D0=97?= =?UTF-8?q?=D0=97=20=D0=B8=D0=B7=20=D0=9D=D0=A1=D0=9F=D0=94,=20=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _compute_confidence получал зону ТОЛЬКО из legacy-словаря `zoning`, который питается таблицей 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' <- зона известна zoning.data_available = False confidence = 0.61 medium подскор zoning = 0.2 оговорка: «ПЗЗ zone_code не известен — нельзя оценить разрешённое использование (G1 pending)» Один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна. Подскоров семь, цена в композите — (1.0 − 0.2)/7 = 0.114: 0.61 вместо 0.72. Тесты переписаны после самопроверки: первая версия падала на origin/main с TypeError, то есть красное означало «возможности нет», а не «значение неверно», и контроли падали там же — контролями они не были. Теперь контроли не передают новый аргумент и зелёные по обе стороны, а тест на уровне ручки красный ПО ПОВЕДЕНИЮ: «ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна». Он же сторожит проводку — параметр можно добавить в функцию и забыть передать. Переформатирование — от pre-commit ruff 0.7.4 (#2864), не моё. --- backend/app/api/v1/parcels.py | 19 +++ .../v1/test_2464_confidence_zoning_source.py | 147 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 backend/tests/api/v1/test_2464_confidence_zoning_source.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 022d737e..ac5a3245 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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 diff --git a/backend/tests/api/v1/test_2464_confidence_zoning_source.py b/backend/tests/api/v1/test_2464_confidence_zoning_source.py new file mode 100644 index 00000000..6878d6e4 --- /dev/null +++ b/backend/tests/api/v1/test_2464_confidence_zoning_source.py @@ -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