176 lines
9.6 KiB
Python
176 lines
9.6 KiB
Python
"""Read-only достаёт персистентный §22-форсайт участка для чата (#957, Step 1).
|
||
|
||
Тонкая обёртка над `latest_run_for(..., schema_version="1.0")` — ровно тот же seam,
|
||
что читает `GET /{cad_num}/forecast` (parcels.py): последний ран ИМЕННО §22-схемы
|
||
('1.0', НЕ analyze-1.0 inline-dict). `run.result` — это `SiteFinderReport.as_dict()`
|
||
(стабильный 8-секционный контракт), `run.id` — run_id для grounding.
|
||
|
||
Q2-РЕШЕНИЕ = READ-ONLY: рана нет → возвращаем (None, None). Эндпоинт по этому отдаёт
|
||
report_status='pending' + детерминированный RU-ответ «отчёт ещё не готов, запустите
|
||
анализ участка». НИЧЕГО не считаем, НЕ enqueue'им, НЕ пере-вычисляем (чат не триггерит
|
||
форсайт — это делает POST /analyze). Сбой БД пробрасываем НЕ из этого модуля —
|
||
гасит/логирует его вызывающий эндпоинт (как get_parcel_forecast → pending).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Версия схемы §22-форсайта (зеркало _FORECAST_SCHEMA_VERSION в parcels.py). Читаем
|
||
# именно её, а не analyze-1.0: view вернул бы §22-ран поверх analyze, но фильтр по
|
||
# схеме явный и устойчивый.
|
||
_FORECAST_SCHEMA_VERSION = "1.0"
|
||
|
||
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
|
||
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
|
||
_MAX_ZOUIT_NAMES = 15
|
||
|
||
|
||
def get_report_for_chat(
|
||
db: Session,
|
||
cad_num: str,
|
||
) -> tuple[dict[str, Any] | None, int | None]:
|
||
"""Достать последний §22-отчёт участка как (report_dict, run_id) или (None, None).
|
||
|
||
READ-ONLY (Q2): рана нужной схемы нет → (None, None), БЕЗ enqueue/пере-расчёта.
|
||
`run.result` — `SiteFinderReport.as_dict()` (8 секций). Если result почему-то не
|
||
dict (мусор/частичная запись) — логируем и отдаём (None, None) как «нет отчёта»,
|
||
чтобы чат не упал (graceful).
|
||
"""
|
||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||
if run is None:
|
||
return None, None
|
||
|
||
result = run.result
|
||
if not isinstance(result, dict):
|
||
# Ран есть, но result не похож на отчёт (старая/битая запись) — не валим чат,
|
||
# трактуем как «отчёта ещё нет» (pending). Логируем для диагностики.
|
||
logger.warning(
|
||
"chat: run %s for cad=%s has non-dict result (%s) — treating as pending",
|
||
run.id,
|
||
cad_num,
|
||
type(result).__name__,
|
||
)
|
||
return None, None
|
||
|
||
return result, run.id
|
||
|
||
|
||
def _as_dict(value: Any) -> dict[str, Any]:
|
||
"""Секция payload как dict или {} (graceful — как _as_dict в full_report_html)."""
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _put(target: dict[str, Any], key: str, value: Any) -> None:
|
||
"""Положить ТОЛЬКО скаляр в курируемый срез (§19 data-residency).
|
||
|
||
Гейт двойной: и по КЛЮЧУ (вызывающий передаёт фиксированный whitelist), и по ТИПУ
|
||
значения. Пропускаем лишь str|int|float|bool — dict/list/любая вложенная структура
|
||
ОТБРАСЫВАЕТСЯ (иначе сырой под-словарь ЕГРН/зонирования с PII мог бы утечь наружу;
|
||
redaction-слой ловит regex-PII в строках, но НЕ структуры). Пустую строку опускаем.
|
||
|
||
UP038: union-форма isinstance (не кортеж) — pre-commit пинит старый ruff.
|
||
"""
|
||
if not isinstance(value, str | int | float | bool):
|
||
return
|
||
if value == "":
|
||
return
|
||
target[key] = value
|
||
|
||
|
||
def _zouit_names(overlaps: list[Any]) -> list[str]:
|
||
"""Собрать dedup-список названий ЗОУИТ-зон (type_zone|name), обрезанный до лимита.
|
||
|
||
§19: ТОЛЬКО строки-названия — никакой геометрии/reg_numb/coverage сырых пересечений.
|
||
Не-строковые значения (dict/list/число) ОТБРАСЫВАЕМ — не приводим str()'ом, иначе
|
||
вложенная структура утекла бы как её repr. dict.fromkeys держит порядок и dedup
|
||
(зеркало _build_zouit в §1-рендере).
|
||
"""
|
||
# Фильтруем до str ДО dict.fromkeys: не-str (dict/list) не только небезопасны для
|
||
# §19, но и unhashable → уронили бы dict.fromkeys TypeError'ом.
|
||
raw = (ov.get("type_zone") or ov.get("name") for ov in overlaps if isinstance(ov, dict))
|
||
names = list(dict.fromkeys(n for n in raw if isinstance(n, str) and n))
|
||
return names[:_MAX_ZOUIT_NAMES]
|
||
|
||
|
||
def get_parcel_context_for_chat(db: Session, cad_num: str) -> dict[str, Any] | None:
|
||
"""Курируемый паспорт участка + градрегламент для чата (§19 data-residency).
|
||
|
||
READ-ONLY: последний analyze-ран (schema_version='analyze-1.0', НЕ §22-форсайт) →
|
||
ПЛОСКИЙ whitelist-дикт из ФИКСИРОВАННОГО набора публичных градо-полей. Сырой
|
||
analyze-blob во внешний LLM ЗАПРЕЩЁН (см. safe_payload.py) — поэтому собираем только
|
||
скаляры и список строк по явным ключам, НИКОГДА не протаскивая под-словари/геометрию.
|
||
|
||
Ключи payload — те же, что читает §1-рендер отчёта (full_report_html:_build_parcel_facts
|
||
/ _build_zoning / _build_zouit): egrn, nspd_zoning, encumbrance, nspd_zouit_overlaps.
|
||
|
||
None если analyze-рана/результата нет (graceful, как get_report_for_chat) — вызывающий
|
||
работает как раньше (только §22-отчёт, без паспорта участка).
|
||
"""
|
||
run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||
if run is None:
|
||
return None
|
||
|
||
result = run.result
|
||
if not isinstance(result, dict):
|
||
logger.warning(
|
||
"chat: analyze run %s for cad=%s has non-dict result (%s) — no parcel context",
|
||
run.id,
|
||
cad_num,
|
||
type(result).__name__,
|
||
)
|
||
return None
|
||
|
||
egrn = _as_dict(result.get("egrn"))
|
||
nspd_zoning = _as_dict(result.get("nspd_zoning"))
|
||
encumbrance = _as_dict(result.get("encumbrance"))
|
||
overlaps = [ov for ov in (result.get("nspd_zouit_overlaps") or []) if isinstance(ov, dict)]
|
||
|
||
context: dict[str, Any] = {}
|
||
|
||
# ── Кадастровые факты (ЕГРН) — скаляры, без геометрии ────────────────────────
|
||
_put(context, "address", egrn.get("address"))
|
||
_put(context, "area_m2", egrn.get("area_m2"))
|
||
_put(context, "land_category", egrn.get("land_category"))
|
||
_put(context, "permitted_use_text", egrn.get("permitted_use_text"))
|
||
_put(context, "cadastral_value_rub", egrn.get("cadastral_value_rub"))
|
||
_put(context, "parcel_status", egrn.get("parcel_status"))
|
||
_put(context, "ownership_type", egrn.get("ownership_type"))
|
||
|
||
# ── Территориальная зона ПЗЗ + лимиты регламента (те же ключи, что §1-рендер) ─
|
||
_put(
|
||
context,
|
||
"zone_code",
|
||
nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index"),
|
||
)
|
||
_put(context, "zone_name", nspd_zoning.get("zone_name"))
|
||
_put(context, "max_far", nspd_zoning.get("max_far"))
|
||
_put(context, "max_floors", nspd_zoning.get("max_floors"))
|
||
_put(context, "max_height_m", nspd_zoning.get("max_height_m"))
|
||
_put(context, "max_building_pct", nspd_zoning.get("max_building_pct"))
|
||
_put(context, "min_parcel_area_m2", nspd_zoning.get("min_parcel_area_m2"))
|
||
_put(context, "regulation_source", nspd_zoning.get("regulation_source"))
|
||
|
||
# ── ЗОУИТ-обременения: сводка + список названий (dedup, capped) ───────────────
|
||
has_zouit = encumbrance.get("has_zouit")
|
||
zouit_count = encumbrance.get("zouit_count")
|
||
zouit_names = _zouit_names(overlaps)
|
||
# Противоречие «encumbrance=нет, но пересечения ЕСТЬ» — доверяем фактам НСПД (тот же
|
||
# приём, что _build_zouit): свежий геослой перекрывает устаревшую сводку.
|
||
if not has_zouit and zouit_names:
|
||
has_zouit = True
|
||
if zouit_count in (None, 0):
|
||
zouit_count = len(overlaps)
|
||
_put(context, "has_zouit", has_zouit)
|
||
_put(context, "zouit_count", zouit_count)
|
||
if zouit_names:
|
||
context["zouit_zone_names"] = zouit_names
|
||
|
||
return context or None
|