gendesign/backend/app/services/chat/retrieval.py
bot-backend 57d156973e
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m31s
feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата (#2366)
2026-07-04 01:24:42 +00:00

176 lines
9.6 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.

"""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