fix(gate): main_vri-authoritative детекция МКД (убрать Ж-1/Ж-2 ИЖС false-positive, включить Ц-2)
PR-A синтезировал zone_code = geoportal-индекс ("Ж-2") → gate.is_residential_zone
матчил ^Ж и считал ЛЮБУЮ Ж-* зону жилой-МКД. Но по zone_regulation_cache.main_vri
(authoritative ВРИ): Ж-1/Ж-2 = только ИЖС (нет кодов МКД) → ^Ж давал FALSE POSITIVE
("можно МКД" на ИЖС-участке — реальный дефект для девелопера); Ц-2 = разрешает МКД
(коды 2.5/2.6) но без Ж-префикса → FALSE NEGATIVE.
Фикс: is_residential_zone получил приоритет-0 — при наличии main_vri решает по нему
авторитетно (mkd_permitted_from_vri: коды 2.1.1/2.5/2.6 = МКД, точное совпадение
токена, "2.50"/"2.59"/"12.0" не матчат), переопределяя ^Ж/subcategory/keyword.
Эвристики остаются ТОЛЬКО как fallback при main_vri=None (dump-путь без geoportal).
parcels.py прокидывает _regulation.main_vri в nspd_zoning на ОБОИХ путях (synth +
dump-augment) → dump-зоны (Ц-2 и пр.) тоже апгрейдятся на main_vri-детекцию.
Унифицирует PR-A+PR-B. Обратная совместимость: main_vri default None.
Проверено по зонам: Ж-3/4/5 permit_mkd=True, Ж-1/2=False(ИЖС), Ц-2=True,
Ц-1/3/4+ЦС-*=False.
Тесты: +22 (mkd_permitted_from_vri true/false/none + no-false-prefix; override
suppress ИЖС / enable Ц-2 / overrides subcategory; fallback при None unchanged;
synth ИЖС-кейс не даёт residential). Полный бэкенд локально: 3442 passed, 0 failed,
coverage 70.3% (gate 65%). ruff чисто. Схема не менялась. Code-review 2×approve 0 major.
Refs #1881 #1891
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
46f021e1c8
commit
de13bfc64c
5 changed files with 257 additions and 2 deletions
BIN
backend/.coverage
Normal file
BIN
backend/.coverage
Normal file
Binary file not shown.
|
|
@ -2120,6 +2120,12 @@ def analyze_parcel(
|
|||
_nspd_zoning["min_parcel_area_m2"] = _regulation.get("min_parcel_area_m2")
|
||||
_nspd_zoning["regulation_zone_index"] = _regulation.get("zone_index")
|
||||
_nspd_zoning["regulation_source"] = "ekb-geoportal-urbancard"
|
||||
# #1891: авторитетный список разрешённых ВРИ зоны → gate.is_residential_zone
|
||||
# принимает по нему МКД-решение (main_vri перебивает ^Ж regex/subcategory).
|
||||
# Применяется к ОБОИМ путям: synth (gap-зона) И dump-augment (даёт dump-зоне
|
||||
# авторитетный сигнал — чинит ИЖС-false-positive Ж-1/Ж-2 и Ц-2-false-negative).
|
||||
# main_vri из regulation dict — уже python list (zone_regulation _SELECT_SQL).
|
||||
_nspd_zoning["main_vri"] = _regulation.get("main_vri")
|
||||
logger.info(
|
||||
"zone regulation hit for %s: zone_index=%s max_far=%s synthesized=%s",
|
||||
cad_num,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,46 @@ _RESIDENTIAL_CODE_RE = re.compile(r"^[Жж]", re.UNICODE)
|
|||
# Fallback по zone_name (lowercase substring) — "жилая", "жилой", "жилая зона" и т.д.
|
||||
RESIDENTIAL_KEYWORDS = ("жил",)
|
||||
|
||||
# ── main_vri (authoritative permitted-uses) ────────────────────────────────────
|
||||
#
|
||||
# zone_regulation_cache.main_vri несёт авторитетный список разрешённых ВРИ зоны
|
||||
# (JSON list of strings, напр. "2.5 Среднеэтажная жилая застройка"). Это единственный
|
||||
# источник, точно различающий ИЖС-зону (Ж-1/Ж-2, только код 2.1/2.2 — МКД нельзя) от
|
||||
# МКД-зоны (Ж-3/Ж-4/Ж-5/Ц-2). regex ^Ж и subcategory — лишь эвристики:
|
||||
# - Ж-1/Ж-2 = ИЖС → ^Ж даёт FALSE POSITIVE ("можно МКД" на ИЖС — реальный дефект)
|
||||
# - Ц-2 разрешает МКД (коды 2.5/2.6), но нет префикса Ж → ^Ж даёт FALSE NEGATIVE
|
||||
# МКД разрешён ⟺ main_vri содержит любой из: 2.1.1 (Малоэтажная многоквартирная),
|
||||
# 2.5 (Среднеэтажная жилая), 2.6 (Многоэтажная жилая). Проверено на проде (true-set
|
||||
# Ж-3/Ж-4/Ж-5/Ц-2; false: Ж-1/Ж-2/Ц-1/Ц-3/Ц-4/ЦС-1..4).
|
||||
_MKD_VRI_CODES: frozenset[str] = frozenset({"2.1.1", "2.5", "2.6"})
|
||||
|
||||
|
||||
def mkd_permitted_from_vri(main_vri: list[str] | None) -> bool | None:
|
||||
"""Authoritative МКД-permission из main_vri (список разрешённых ВРИ зоны).
|
||||
|
||||
Returns:
|
||||
None — main_vri отсутствует/пуст (нет информации, нужен fallback);
|
||||
True — хотя бы один элемент имеет ведущий код-токен из _MKD_VRI_CODES;
|
||||
False — иначе (ВРИ есть, но МКД-кодов среди них нет).
|
||||
|
||||
Матчинг по ТОЧНОМУ дотированному код-токену: элемент выглядит как
|
||||
"2.5 Среднеэтажная жилая застройка" или "2.1.1\\n Малоэтажная многоквартирная …",
|
||||
иногда просто "2.5". Берём первый whitespace-разделённый токен и сравниваем на
|
||||
равенство с set — поэтому "2.50"/"2.59"/"12.0" НЕ матчат "2.5" (нет ложного
|
||||
prefix-совпадения).
|
||||
"""
|
||||
if not main_vri:
|
||||
return None
|
||||
for elem in main_vri:
|
||||
if not elem:
|
||||
continue
|
||||
# Первый whitespace-разделённый токен (split разбивает по \n/\t/пробелам).
|
||||
token = str(elem).split(None, 1)[0] if str(elem).split() else ""
|
||||
if token in _MKD_VRI_CODES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# NSPD territorial zones layer 875838: subcategory (int) → тип зоны.
|
||||
# Значения получены из реальных данных nspd_quarter_dumps (ЕКБ, 2026-02):
|
||||
# subcategory=1 — 39 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы)
|
||||
|
|
@ -98,11 +138,17 @@ def is_residential_zone(
|
|||
zone_code: str | None,
|
||||
zone_name: str | None,
|
||||
raw_props: dict | None = None,
|
||||
main_vri: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if ПЗЗ zone permits residential (МКД) building.
|
||||
|
||||
Detection strategy (in priority order):
|
||||
|
||||
0. main_vri (AUTHORITATIVE) — авторитетный список разрешённых ВРИ зоны из
|
||||
zone_regulation_cache. Если непустой → решение принимается ТОЛЬКО по нему
|
||||
(mkd_permitted_from_vri), перебивая regex/subcategory/keyword. Это и подавляет
|
||||
FALSE POSITIVE на ИЖС (Ж-1/Ж-2), и включает FALSE NEGATIVE (Ц-2 без префикса Ж).
|
||||
|
||||
1. zone_code regex «^Ж» — ПЗЗ коды PKK6/pzz_zones_ekb: "Ж-1", "Жс", "ЖС-2" и т.д.
|
||||
NSPD quarter-dump кладёт в zone_code кадастровый рег.номер ("66:41-7.14") —
|
||||
он не начинается на «Ж», ложных срабатываний нет.
|
||||
|
|
@ -113,7 +159,17 @@ def is_residential_zone(
|
|||
допускают МКД по ПЗЗ ЕКБ. subcategory=1 (ИЖС) — намеренно исключён.
|
||||
|
||||
3. zone_name substring «жил» — fallback для нестандартных источников.
|
||||
|
||||
Эвристики 1-3 применяются ТОЛЬКО как fallback когда main_vri None/пуст
|
||||
(dump-путь без geoportal main_vri). main_vri по умолчанию None — backward-compat
|
||||
для существующих вызовов/тестов.
|
||||
"""
|
||||
# 0. main_vri — authoritative permitted-uses (overrides all heuristics)
|
||||
if main_vri:
|
||||
verdict = mkd_permitted_from_vri(main_vri)
|
||||
if verdict is not None:
|
||||
return verdict
|
||||
|
||||
# 1. PKK6-style zone_code: starts with Ж (case-insensitive)
|
||||
if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code):
|
||||
return True
|
||||
|
|
@ -188,6 +244,7 @@ def compute_gate_verdict(
|
|||
nspd_zoning.get("zone_code"),
|
||||
nspd_zoning.get("zone_name"),
|
||||
nspd_zoning.get("raw_props"),
|
||||
nspd_zoning.get("main_vri"),
|
||||
):
|
||||
zone_label = (
|
||||
nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна"
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ _REGULATION = {
|
|||
}
|
||||
|
||||
# PR-A synth fixtures: жилая зона Ж-2 (gate должен пустить МКД) с числами для финмодели.
|
||||
# #1891: main_vri теперь авторитетный сигнал для gate — Ж-2 здесь МКД-зона (код 2.5),
|
||||
# поэтому добавляем main_vri с МКД-кодом, чтобы synth→gate дал can_build_mkd=True.
|
||||
_REGULATION_ZH2 = {
|
||||
"zone_index": "Ж-2",
|
||||
"max_far": 2.5,
|
||||
|
|
@ -63,6 +65,23 @@ _REGULATION_ZH2 = {
|
|||
"max_floors": 25,
|
||||
"max_building_pct": 40.0,
|
||||
"min_parcel_area_m2": 600.0,
|
||||
"main_vri": ["2.5 Среднеэтажная жилая застройка", "2.6 Многоэтажная жилая застройка"],
|
||||
"source": "ekb_geoportal",
|
||||
}
|
||||
|
||||
# #1891: ИЖС-зона — main_vri БЕЗ МКД-кодов (только 2.1/2.2). gate.is_residential_zone
|
||||
# по main_vri должен дать False, подавляя ^Ж-regex false-positive (Ж-2 ИЖС ≠ МКД).
|
||||
_REGULATION_IZHS = {
|
||||
"zone_index": "Ж-2",
|
||||
"max_far": 0.4,
|
||||
"max_height_m": 12.0,
|
||||
"max_floors": 3,
|
||||
"max_building_pct": 30.0,
|
||||
"min_parcel_area_m2": 400.0,
|
||||
"main_vri": [
|
||||
"2.1 Для индивидуального жилищного строительства",
|
||||
"2.2 Для ведения личного подсобного хозяйства (приусадебный земельный участок)",
|
||||
],
|
||||
"source": "ekb_geoportal",
|
||||
}
|
||||
|
||||
|
|
@ -322,7 +341,7 @@ def test_zoning_regulation_skipped_when_flag_off() -> None:
|
|||
|
||||
|
||||
def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None:
|
||||
"""(a) dump nspd_zoning=None + geoportal "Ж-2"+max_far → synth + gate видит жилую."""
|
||||
"""(a) dump nspd_zoning=None + geoportal "Ж-2"+main_vri(МКД) → synth + gate видит жилую."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze()
|
||||
|
|
@ -351,7 +370,9 @@ def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None:
|
|||
assert zoning["max_far"] == 2.5
|
||||
assert zoning["regulation_zone_index"] == "Ж-2"
|
||||
assert zoning["regulation_source"] == "ekb-geoportal-urbancard"
|
||||
# gate увидел synth-зону как жилую → can_build_mkd=True
|
||||
# #1891: main_vri проброшен из regulation в synth-зону
|
||||
assert "2.5 Среднеэтажная жилая застройка" in zoning["main_vri"]
|
||||
# gate увидел synth-зону как жилую по main_vri → can_build_mkd=True
|
||||
assert body["gate_verdict"]["can_build_mkd"] is True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
|
@ -359,6 +380,44 @@ def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None:
|
|||
p.stop()
|
||||
|
||||
|
||||
def test_synth_izhs_main_vri_suppresses_residential_false_positive() -> None:
|
||||
"""#1891: dump None + geoportal "Ж-2" ИЖС (main_vri без МКД-кодов) → gate НЕ жилая.
|
||||
|
||||
Раньше synth клал zone_code="Ж-2" → gate ^Ж regex давал can_build_mkd=True даже на
|
||||
ИЖС-зоне (FALSE POSITIVE). Теперь main_vri (авторитетный) перебивает regex → gate
|
||||
видит non-residential → can_build_mkd is NOT True.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
patches = [
|
||||
*_base_patches(dump_zoning=None, dump_available=True, mock_gate=False),
|
||||
patch(
|
||||
"app.api.v1.parcels.get_or_fetch_zone_regulation",
|
||||
return_value=dict(_REGULATION_IZHS),
|
||||
),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
zoning = body["nspd_zoning"]
|
||||
assert zoning is not None
|
||||
assert zoning["zone_code"] == "Ж-2" # display-индекс цел
|
||||
# main_vri проброшен — авторитетный сигнал «ИЖС, МКД нельзя»
|
||||
assert "2.1 Для индивидуального жилищного строительства" in zoning["main_vri"]
|
||||
# FIX: false-positive подавлен — gate НЕ даёт can_build_mkd True на ИЖС
|
||||
assert body["gate_verdict"]["can_build_mkd"] is not True
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
def test_no_synth_when_dump_none_and_geoportal_none() -> None:
|
||||
"""(b) dump nspd_zoning=None + geoportal None → nspd_zoning None, financial None, не падаем."""
|
||||
from app.core.db import get_db
|
||||
|
|
|
|||
|
|
@ -3,8 +3,19 @@
|
|||
from app.services.site_finder.gate_verdict import (
|
||||
compute_gate_verdict,
|
||||
is_residential_zone,
|
||||
mkd_permitted_from_vri,
|
||||
)
|
||||
|
||||
# Реальные формы main_vri из zone_regulation_cache (JSON list of strings).
|
||||
_VRI_IZHS_ONLY = [
|
||||
"2.1 Для индивидуального жилищного строительства",
|
||||
"2.2 Для ведения личного подсобного хозяйства (приусадебный земельный участок)",
|
||||
]
|
||||
_VRI_HAS_MKD_25 = ["2.5 Среднеэтажная жилая застройка"]
|
||||
_VRI_HAS_MKD_26 = ["2.6 Многоэтажная жилая застройка (высотная застройка)"]
|
||||
_VRI_HAS_MKD_211 = ["2.1.1\n Малоэтажная многоквартирная жилая застройка"]
|
||||
_VRI_NO_MKD = ["1.13 Овощеводство / ИЖС", "2.7.1 Хранение автотранспорта"]
|
||||
|
||||
# ── is_residential_zone unit tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -43,6 +54,97 @@ def test_non_residential_synth_cs3():
|
|||
assert is_residential_zone("ЦС-3", None, None) is False
|
||||
|
||||
|
||||
# ── mkd_permitted_from_vri (authoritative main_vri matching, #1891) ───────────
|
||||
|
||||
|
||||
def test_mkd_vri_true_for_2_5():
|
||||
assert mkd_permitted_from_vri(_VRI_HAS_MKD_25) is True
|
||||
|
||||
|
||||
def test_mkd_vri_true_for_2_6():
|
||||
assert mkd_permitted_from_vri(_VRI_HAS_MKD_26) is True
|
||||
|
||||
|
||||
def test_mkd_vri_true_for_2_1_1_with_newline():
|
||||
"""Код может склеиться с \\n — split берёт первый токен '2.1.1'."""
|
||||
assert mkd_permitted_from_vri(_VRI_HAS_MKD_211) is True
|
||||
|
||||
|
||||
def test_mkd_vri_false_for_izhs_only():
|
||||
assert mkd_permitted_from_vri(_VRI_IZHS_ONLY) is False
|
||||
|
||||
|
||||
def test_mkd_vri_false_for_no_mkd_codes():
|
||||
assert mkd_permitted_from_vri(_VRI_NO_MKD) is False
|
||||
|
||||
|
||||
def test_mkd_vri_none_for_none():
|
||||
assert mkd_permitted_from_vri(None) is None
|
||||
|
||||
|
||||
def test_mkd_vri_none_for_empty_list():
|
||||
assert mkd_permitted_from_vri([]) is None
|
||||
|
||||
|
||||
def test_mkd_vri_no_false_prefix_2_50():
|
||||
"""'2.50' НЕ должен матчить '2.5' (точный токен-матч, не prefix)."""
|
||||
assert mkd_permitted_from_vri(["2.50 Какой-то ВРИ"]) is False
|
||||
|
||||
|
||||
def test_mkd_vri_no_false_prefix_2_59():
|
||||
assert mkd_permitted_from_vri(["2.59 Какой-то ВРИ"]) is False
|
||||
|
||||
|
||||
def test_mkd_vri_no_false_prefix_12_0():
|
||||
"""'12.0' НЕ должен матчить '2.6'/'2.5' (substring guard)."""
|
||||
assert mkd_permitted_from_vri(["12.0 Земельные участки общего пользования"]) is False
|
||||
|
||||
|
||||
def test_mkd_vri_code_only_element():
|
||||
"""Элемент без названия (просто код) тоже матчит."""
|
||||
assert mkd_permitted_from_vri(["2.6"]) is True
|
||||
|
||||
|
||||
# ── is_residential_zone: main_vri authoritative override (#1891) ──────────────
|
||||
|
||||
|
||||
def test_residential_main_vri_suppresses_izhs_false_positive():
|
||||
"""Ж-2 ИЖС: ^Ж regex дал бы True, но main_vri (ИЖС-only) перебивает → False (FIX)."""
|
||||
assert is_residential_zone("Ж-2", None, None, main_vri=_VRI_IZHS_ONLY) is False
|
||||
|
||||
|
||||
def test_residential_main_vri_enables_ts2_false_negative():
|
||||
"""Ц-2: нет префикса Ж (^Ж дал бы False), но main_vri содержит 2.6 → True (FIX)."""
|
||||
assert is_residential_zone("Ц-2", None, None, main_vri=_VRI_HAS_MKD_26) is True
|
||||
|
||||
|
||||
def test_residential_main_vri_zh5_with_mkd_true():
|
||||
"""Ж-5 с main_vri 2.5 → True (authoritative совпадает с regex)."""
|
||||
assert is_residential_zone("Ж-5", None, None, main_vri=_VRI_HAS_MKD_25) is True
|
||||
|
||||
|
||||
def test_residential_main_vri_overrides_subcategory():
|
||||
"""main_vri ИЖС перебивает даже raw_props subcategory=2 (authoritative wins)."""
|
||||
assert (
|
||||
is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False
|
||||
)
|
||||
|
||||
|
||||
def test_residential_empty_main_vri_falls_back_to_regex():
|
||||
"""main_vri=[] (нет инфо) → fallback на ^Ж regex → True."""
|
||||
assert is_residential_zone("Ж-2", None, None, main_vri=[]) is True
|
||||
|
||||
|
||||
def test_residential_none_main_vri_fallback_regex_unchanged():
|
||||
"""main_vri=None → regex fallback: Ж-2 остаётся True (backward-compat)."""
|
||||
assert is_residential_zone("Ж-2", None, None) is True
|
||||
|
||||
|
||||
def test_residential_none_main_vri_fallback_subcategory_unchanged():
|
||||
"""main_vri=None → subcategory fallback продолжает работать."""
|
||||
assert is_residential_zone(None, None, {"subcategory": 2}) is True
|
||||
|
||||
|
||||
# ── compute_gate_verdict ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -374,3 +476,34 @@ def test_verdict_nspd_zoning_no_rawprops_no_zone_blocks():
|
|||
assert verdict["can_build_mkd"] is False
|
||||
assert verdict["verdict_label"] == "Нельзя"
|
||||
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])
|
||||
|
||||
|
||||
# ── compute_gate_verdict: main_vri authoritative (#1891) ──────────────────────
|
||||
|
||||
|
||||
def test_verdict_main_vri_izhs_blocks_despite_zh_code():
|
||||
"""nspd_zoning Ж-2 + main_vri ИЖС-only → gate видит non-residential → Нельзя (FIX)."""
|
||||
nspd_zoning = {
|
||||
"zone_code": "Ж-2",
|
||||
"zone_name": "Зона Ж-2",
|
||||
"raw_props": None,
|
||||
"main_vri": _VRI_IZHS_ONLY,
|
||||
}
|
||||
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
|
||||
assert verdict["can_build_mkd"] is False
|
||||
assert verdict["verdict_label"] == "Нельзя"
|
||||
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])
|
||||
|
||||
|
||||
def test_verdict_main_vri_enables_ts2_can_build():
|
||||
"""nspd_zoning Ц-2 (нет ^Ж) + main_vri 2.6 → gate видит жилую → Можно (FIX)."""
|
||||
nspd_zoning = {
|
||||
"zone_code": "Ц-2",
|
||||
"zone_name": "Общественно-деловая Ц-2",
|
||||
"raw_props": None,
|
||||
"main_vri": _VRI_HAS_MKD_26,
|
||||
}
|
||||
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-7"}], {"available": True})
|
||||
assert verdict["can_build_mkd"] is True
|
||||
assert verdict["verdict_label"] == "Можно"
|
||||
assert verdict["blockers"] == []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue