gendesign/backend/app/services/site_finder/gate_verdict.py
bot-backend ac19ef4823
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (push) Failing after 1m40s
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / backend-tests (push) Failing after 8m47s
fix(site-finder): NO_ENGINEERING_NEARBY is a warning, not a verdict blocker (#1617)
Introduce INFORMATIONAL_WARNING_CODES frozenset; verdict assembly now only
downgrades to «С ограничениями» when verdict-relevant warnings exist.
NO_ENGINEERING_NEARBY still appears in warnings[] for display but a clean
residential parcel (Ж-zone, no ЗОУИТ) correctly receives verdict_label=«Можно».
2026-06-17 20:59:32 +03:00

340 lines
16 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 = ("жил",)
# 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,
) -> bool:
"""Check if ПЗЗ zone permits residential (МКД) building.
Detection strategy (in priority order):
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. 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"),
):
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,
)