gendesign/backend/app/services/site_finder/gate_verdict.py
Light1YT de13bfc64c
Some checks failed
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (pull_request) Failing after 12m21s
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>
2026-06-24 03:59:31 +05:00

397 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gate Verdict aggregator — собирает can-build-MKD signal из NSPD lookups.
Per #32 G5: user-facing answer на главный вопрос девелопера "Можно ли тут МКД?"
Foundation: nspd_zoning, nspd_zouit_overlaps, nspd_engineering_nearby (Sprint 1.1).
Pure function — no DB dependencies, consumes already-fetched data from analyze_parcel.
"""
from __future__ import annotations
import re
from typing import Literal, TypedDict
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
network_kind_label,
)
# ── Zone matching ─────────────────────────────────────────────────────────────
# Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д.
# Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках.
# NSPD quarter-dump в zone_code кладёт кадастровый рег.номер (напр. "66:41-7.14") —
# он никогда не начинается на "Ж", поэтому этот regex не даст false positive.
_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 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы)
# subcategory=2 — 221 объект — Жилые зоны (МКД разрешён, крупнейшая группа ЕКБ)
# subcategory=3 — 126 объектов — Зоны смешанного использования (МКД по ПЗЗ ЕКБ допустим)
# subcategory=4 — 77 объектов — Общественно-деловые
# subcategory=5 — 112 объектов — Производственные
# subcategory=6 — 92 объекта — Рекреационные
# subcategory=8 — 16 объектов — Специальные
# subcategory=10 — 113 объектов — Промышленные/транспортные
# subcategory=1 исключён намеренно: ИЖС-зона МКД не допускает.
NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3})
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
# Используется для overlaps из NSPD dump (subcategory INT там заполнен).
BLOCKER_SUBCATEGORIES: dict[int, str] = {
17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)",
}
# Keyword-based blockers для cad_zouit fallback (#232).
# cad_zouit.subcategory = 100% NULL, поэтому классификация по type_zone substring.
# Порядок: lowercase, substring match (any wins → blocker).
BLOCKER_TYPE_ZONE_KEYWORDS: tuple[str, ...] = (
"охранная зона",
"трубопровод",
"электр",
"газ",
)
# type_zone substrings, которые дают warning (не blocker) из cad_zouit.
WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-защитная")
# Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py
ENGINEERING_NEARBY_THRESHOLD_M = 200
# Warning codes that are purely informational — they appear in warnings[] for the user
# but do NOT downgrade the final verdict from «Можно» to «С ограничениями» (#1617).
# A clean residential parcel with only these warnings still gets verdict_label="Можно".
INFORMATIONAL_WARNING_CODES: frozenset[str] = frozenset({"NO_ENGINEERING_NEARBY"})
# ── TypedDicts ────────────────────────────────────────────────────────────────
class Blocker(TypedDict):
code: str
detail: str
class Warning(TypedDict):
code: str
detail: str
class GateVerdict(TypedDict):
can_build_mkd: bool | Literal["unknown"]
verdict_label: str # "Можно" | "Нельзя" | "С ограничениями" | "Нужна проверка"
blockers: list[Blocker]
warnings: list[Warning]
checks_performed: list[str]
source: Literal["nspd_dump", "nspd_dump_partial", "no_data"]
# ── Helpers ───────────────────────────────────────────────────────────────────
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") —
он не начинается на «Ж», ложных срабатываний нет.
2. NSPD subcategory из raw_props — единственный надёжный тип-дискриминатор в
реальных данных nspd_quarter_dumps (layer 875838, ЕКБ 2026-02):
subcategory=2 ("Жилые зоны") и subcategory=3 ("Смешанное использование")
допускают МКД по ПЗЗ ЕКБ. 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
# 2. NSPD subcategory from raw_props (primary discriminator for quarter-dump data)
if raw_props:
sub_raw = raw_props.get("subcategory")
if sub_raw is not None:
try:
if int(sub_raw) in NSPD_RESIDENTIAL_SUBCATEGORIES:
return True
except (ValueError, TypeError):
pass
# 3. zone_name substring fallback
if zone_name:
zn_lower = zone_name.lower()
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
return True
return False
# ── Main aggregator ───────────────────────────────────────────────────────────
def compute_gate_verdict(
nspd_zoning: dict | None,
nspd_zouit_overlaps: list[dict] | None,
nspd_engineering_nearby: list[dict] | None,
nspd_dump: dict | None,
) -> GateVerdict:
"""Aggregate NSPD lookups into can-build-MKD verdict.
Checks performed (in order):
1. ПЗЗ residential zone match (blocker if non-residential)
2. ЗОУИТ subcategory 17 overlap (blocker); other subcategories → warnings
3. Engineering nearby presence check (warning-only, not a blocker)
Returns GateVerdict с blockers/warnings/checks_performed и source label.
"""
blockers: list[Blocker] = []
warnings: list[Warning] = []
checks: list[str] = []
# Guard: no NSPD data at all
dump_available = (nspd_dump or {}).get("available", False)
dump_stale = (nspd_dump or {}).get("stale", False)
if not dump_available:
return GateVerdict(
can_build_mkd="unknown",
verdict_label="Нужна проверка",
blockers=[],
warnings=[
Warning(
code="NO_NSPD_DUMP",
detail="Данные NSPD ещё не подгружены",
)
],
checks_performed=[],
source="no_data",
)
source: Literal["nspd_dump", "nspd_dump_partial"] = (
"nspd_dump_partial" if dump_stale else "nspd_dump"
)
# Check 1 — ПЗЗ residential zone
checks.append("ПЗЗ зональность")
if nspd_zoning:
if not is_residential_zone(
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 "неизвестна"
)
blockers.append(
Blocker(
code="PZZ_NOT_RESIDENTIAL",
detail=f"Зона: {zone_label}",
)
)
else:
warnings.append(
Warning(
code="PZZ_UNKNOWN",
detail="ПЗЗ зона не определена",
)
)
# Check 2 — ЗОУИТ overlaps
checks.append("ЗОУИТ пересечения")
for overlap in nspd_zouit_overlaps or []:
src = overlap.get("source", "nspd-quarter-dump")
if src == "cad_zouit":
# cad_zouit fallback path: classify by type_zone keywords (#232).
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
# #1070: классификация «сетевого обременения» (охранная зона инж.сети).
# Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind);
# если overlap из старого пути без флага — определяем здесь же.
net_kind = overlap.get("network_kind")
if net_kind is None and overlap.get("is_network_zone") is None:
net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer"))
# Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная'
# специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной
# станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое
# обременение (#1070, выделенный код раньше широких подстрок), затем общие
# blocker-keywords, иначе — generic warning.
if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
warnings.append(
Warning(
code="ZOUIT_CAD_SZZ",
detail=(
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
),
)
)
elif net_kind is not None:
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
blockers.append(
Blocker(
code="ZOUIT_NETWORK_OBREMENENIE",
detail=(
f"Сетевое обременение ({kind_label}): "
f"{overlap.get('type_zone', '')} {overlap.get('name', '')}".strip()
),
)
)
elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
blockers.append(
Blocker(
code="ZOUIT_CAD_BLOCKER",
detail=(
f"Охранная зона ({overlap.get('type_zone', '')}): "
f"{overlap.get('name', '')}"
),
)
)
else:
warnings.append(
Warning(
code="ZOUIT_CAD_OTHER",
detail=(
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): "
f"{overlap.get('name', '')}"
),
)
)
else:
# NSPD dump path: subcategory-based logic (backward-compat).
# subcategory из _get_zouit_overlaps = `subcategory or type_zone` → может быть
# строкой ('17') или fallback на type_zone (#1360). Нормализуем к int как
# _get_zouit_engineering_overlaps, иначе blocker subcategory-17 тихо
# деградирует до warning.
sub_raw = overlap.get("subcategory")
sub: int | None
if isinstance(sub_raw, bool):
sub = None
else:
try:
sub = int(sub_raw) # type: ignore[arg-type]
except (ValueError, TypeError):
sub = None
if sub is not None and sub in BLOCKER_SUBCATEGORIES:
blockers.append(
Blocker(
code=f"ZOUIT_OVERLAP_SUB{sub}",
detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}",
)
)
else:
# Сохраняем исходную метку (str/int) в warning-коде если нормализация
# не дала int — иначе теряется человекочитаемый ярлык subcategory.
if sub is not None:
sub_label: int | str = sub
elif sub_raw is not None:
sub_label = sub_raw
else:
sub_label = "unknown"
warnings.append(
Warning(
code=f"ZOUIT_SUB{sub_label}",
detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}",
)
)
# Check 3 — Engineering nearby (warning only, not a blocker)
checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м")
if not (nspd_engineering_nearby or []):
warnings.append(
Warning(
code="NO_ENGINEERING_NEARBY",
detail=f"Сетей не найдено в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м",
)
)
# Final verdict.
# Informational warnings (e.g. NO_ENGINEERING_NEARBY) appear in warnings[] for display
# but do NOT by themselves downgrade a clean parcel to «С ограничениями» (#1617).
verdict_relevant_warnings = [
w for w in warnings if w["code"] not in INFORMATIONAL_WARNING_CODES
]
can_build: bool | Literal["unknown"]
if blockers:
can_build = False
label = "Нельзя"
elif verdict_relevant_warnings:
can_build = True
label = "С ограничениями"
else:
can_build = True
label = "Можно"
return GateVerdict(
can_build_mkd=can_build,
verdict_label=label,
blockers=blockers,
warnings=warnings,
checks_performed=checks,
source=source,
)