gendesign/backend/app/services/chat/retrieval.py
bot-backend 7b3d1a8f85
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 14m34s
feat(chat): add get_parcel_info tool exposing curated analyze градрегламент
Chat could not answer 'какая зона ПЗЗ?' — retrieval loaded only the §22
forecast run (schema 1.0); тер.зона/градрегламент/ЗОУИТ/ЕГРН live in the
analyze-1.0 run and never reached the chat context.

Add get_parcel_context_for_chat: a §19-safe whitelist slice of analyze
(fixed keys AND scalar-only values — dict/list dropped at the boundary;
ЗОУИТ zone names as a dedicated str-only capped list; no geometry/PII/raw
sub-dicts) merged into a copy of report_dict under 'parcel_context';
expose it via the sixth sectional tool get_parcel_info. Bump chat_system
prompt v2->v3 so the model uses it and no longer treats the parcel's own
ПЗЗ zone as out of scope.
2026-07-04 06:08:47 +05: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