feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата #2366

Merged
bot-backend merged 1 commit from fix/chat-parcel-info into main 2026-07-04 01:24:43 +00:00
9 changed files with 599 additions and 23 deletions

View file

@ -35,7 +35,11 @@ from app.core.db import get_db
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
from app.services.chat.intents import render_answer, route_intent
from app.services.chat.orchestrator import orchestrate_chat
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
from app.services.chat.retrieval import (
_FORECAST_SCHEMA_VERSION,
get_parcel_context_for_chat,
get_report_for_chat,
)
logger = logging.getLogger(__name__)
@ -92,11 +96,46 @@ async def ask(
report_status="pending",
)
# Курируемый паспорт участка + градрегламент (§1 analyze-рана) — отдельный read-only
# seam. §22-отчёт (форсайт) НЕ несёт тер.зону/ЗОУИТ/ЕГРН, поэтому дотягиваем их из
# analyze-1.0 и вливаем в КОПИЮ report_dict под ключ "parcel_context" (tool
# get_parcel_info режет именно его). Analyze-рана нет/сбой чтения → работаем как
# раньше (только форсайт); НЕ меняем pending-поведение (оно завязано на §22-ран выше).
report = await _with_parcel_context(db, payload.cad_num, report)
if settings.llm_enabled:
return await _answer_via_llm(db, payload, report, run_id)
return _answer_deterministic(payload, report, run_id)
async def _with_parcel_context(
db: Session,
cad_num: str,
report: dict[str, Any],
) -> dict[str, Any]:
"""Дотянуть курируемый паспорт участка и влить его в КОПИЮ report_dict.
Read-only: sync-чтение analyze-рана мостим через run_in_threadpool (как §22-отчёт).
None (рана нет) возвращаем report без изменений. Сбой БД глотаем в pending-стиле
эндпоинта: паспорт участка обогащение, его отсутствие не должно ронять чат.
"""
try:
parcel_context = await run_in_threadpool(get_parcel_context_for_chat, db, cad_num)
except Exception:
logger.warning(
"chat: parcel context read failed for cad=%s — continuing without it",
cad_num,
exc_info=True,
)
return report
if not parcel_context:
return report
# Копия: не мутируем report_dict, пришедший из get_report_for_chat.
merged = dict(report)
merged["parcel_context"] = parcel_context
return merged
def _answer_deterministic(
payload: ChatAskRequest,
report: dict[str, Any],

View file

@ -19,7 +19,7 @@ from typing import Any
from sqlalchemy.orm import Session
from app.services.analysis_runs.repository import latest_run_for
from app.services.analysis_runs.repository import ANALYZE_SCHEMA_VERSION, latest_run_for
logger = logging.getLogger(__name__)
@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
# схеме явный и устойчивый.
_FORECAST_SCHEMA_VERSION = "1.0"
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
_MAX_ZOUIT_NAMES = 15
def get_report_for_chat(
db: Session,
@ -57,3 +61,116 @@ def get_report_for_chat(
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

View file

@ -14,9 +14,14 @@ tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, чер
РАЗРЕШЕНО в ``section_data`` / ``fields``:
срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
scoring / confidence / scenarios) это посчитанные advisory-агрегаты.
``parcel_context`` КУРИРУЕМЫЙ срез analyze-рана (retrieval.get_parcel_context_for_chat):
публичные градоданные участка (адрес, площадь, категория, ВРИ, тер.зона ПЗЗ + лимиты
застройки, ЗОУИТ-сводка + названия зон). Строится по фиксированному whitelist'у из
скаляров и списка строк БЕЗ геометрии/координат, без сырых под-словарей, без PII.
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
сырой ``analyze``-blob / сырые строки БД (rosreestr_deals, parcels, );
сырой ``analyze``-blob целиком / сырые строки БД (rosreestr_deals, parcels, );
геометрия/координаты участка или ЗОУИТ-пересечений;
свободный insight-текст / лиды / любой контент с PII;
любой источник, помеченный confidential.

View file

@ -3,10 +3,11 @@
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
Здесь две вещи и НИЧЕГО больше:
1. OpenAI tool-спеки (JSON-schema) пяти read-only секционных tool'ов
1. OpenAI tool-спеки (JSON-schema) шести read-only секционных tool'ов
(`get_exec_summary` / `get_product_recommendation` / `get_forecast` /
`get_risks` / `get_scenarios`). Без параметров каждый отдаёт фиксированную
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
`get_risks` / `get_scenarios` / `get_parcel_info`). Без параметров каждый отдаёт
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
данные).
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
НИКАКОЙ движковой математики только срез готового dict'а.
@ -73,6 +74,16 @@ def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
return _section(report, "scenarios")
def get_parcel_info(report: dict[str, Any]) -> dict[str, Any]:
"""§1 parcel_context — паспорт участка + градрегламент ПЗЗ + ЗОУИТ.
Курируемый срез analyze-рана (тер.зона ПЗЗ, ВРИ, лимиты застройки, ЗОУИТ), влитый
эндпоинтом под ключ "parcel_context" (retrieval.get_parcel_context_for_chat). Секции
в отчёте нет (analyze-рана не было) маркер «недоступно», как у остальных tool'ов.
"""
return _section(report, "parcel_context")
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
@ -84,6 +95,7 @@ _TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, .
"get_forecast": (get_forecast, ("future_market",)),
"get_risks": (get_risks, ("scoring", "confidence")),
"get_scenarios": (get_scenarios, ("scenarios",)),
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
}
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
@ -103,8 +115,11 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
"и уровень/факторы уверенности отчёта."
),
"get_scenarios": (
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
"get_scenarios": ("Сценарии развития: разброс по консервативному / базовому / агрессивному."),
"get_parcel_info": (
"Паспорт участка и градостроительный регламент: адрес, площадь, категория земель, "
"ВРИ, территориальная зона ПЗЗ (код и название), лимиты застройки, "
"ЗОУИТ-обременения."
),
}
@ -128,7 +143,7 @@ def _spec(name: str) -> dict[str, Any]:
def tool_specs() -> list[dict[str, Any]]:
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
"""Все 6 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
return [_spec(name) for name in _TOOLS]
@ -161,6 +176,7 @@ __all__ = [
"execute_tool",
"get_exec_summary",
"get_forecast",
"get_parcel_info",
"get_product_recommendation",
"get_risks",
"get_scenarios",

View file

@ -48,22 +48,24 @@ _SYSTEM_BASE = PromptTemplate(
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
_CHAT_SYSTEM = PromptTemplate(
name="chat_system",
version=2,
version=3,
template=(
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
"ЖЁСТКИЕ ПРАВИЛА:\n"
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
"отчёте нет. Не подставляй правдоподобные значения.\n"
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
"инвестиционного решения. Не давай гарантий доходности.\n"
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
"области отчёта, и предложи вопросы по самому форсайту.\n"
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "

View file

@ -68,9 +68,7 @@ def _report() -> dict[str, Any]:
"summary": "Разброс умеренный.",
},
"scoring": {
"special_indices": {
"indices": {"cannibalization": {"value": 0.31, "label": None}}
},
"special_indices": {"indices": {"cannibalization": {"value": 0.31, "label": None}}},
},
"confidence": {
"level": "medium",
@ -454,6 +452,130 @@ def test_ask_llm_enabled_degrades_to_deterministic_on_failure(
assert f"250{_NBSP}000" in body["answer"]
# ── parcel_context: паспорт участка вливается в report_dict и доступен tool'у ─────
class _ParcelInfoProvider(LLMProvider):
"""Fake: 1-й вызов — tool-call get_parcel_info, 2-й — финальная проза (grounding)."""
def __init__(self) -> None:
self.calls = 0
self.captured_fields: dict[str, Any] = {}
@property
def is_external(self) -> bool:
return True
@property
def model(self) -> str:
return "gpt-4o-mini"
def complete(
self,
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]] | None = None,
max_output_tokens: int,
) -> ProviderResponse:
self.calls += 1
if self.calls == 1:
return ProviderResponse(
content=None,
tool_calls=[ToolCall(id="p1", name="get_parcel_info", arguments="{}")],
model="gpt-4o-mini",
)
# Второй ход: срез паспорта участка уже в user-сообщении (fields) — фиксируем факт,
# что зона ПЗЗ реально долетела до модели.
self.captured_fields = {"seen": "\n".join(m.get("content") or "" for m in messages)}
return ProviderResponse(content="Зона ПЗЗ участка: Ж-4.", model="gpt-4o-mini")
def test_ask_merges_parcel_context_and_tool_can_read_it(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""§22-отчёт есть → эндпоинт дотягивает parcel_context; get_parcel_info его отдаёт LLM."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
fake = _ParcelInfoProvider()
monkeypatch.setattr("app.services.llm.client._build_default_provider", lambda: fake)
parcel_ctx = {"zone_code": "Ж-4", "zone_name": "Зона многоэтажной застройки"}
client = _client_with_db()
with (
patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
),
patch(
"app.api.v1.chat.get_parcel_context_for_chat",
return_value=parcel_ctx,
) as mock_ctx,
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "какая зона ПЗЗ?"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["llm_used"] is True
assert body["answer"] == "Зона ПЗЗ участка: Ж-4."
# provenance содержит parcel_context (tool get_parcel_info реально отработал).
assert body["grounded_in"]["sections"] == ["parcel_context"]
# Паспорт участка дотягивался ровно для этого cad_num.
mock_ctx.assert_called_once()
assert mock_ctx.call_args.args[1] == _CAD
# Зона ПЗЗ реально попала в контекст, отправленный модели.
assert "Ж-4" in fake.captured_fields["seen"]
def test_ask_no_parcel_context_falls_back_to_report_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Analyze-рана нет (parcel_context=None) → чат работает как раньше (только форсайт)."""
monkeypatch.setattr(settings, "llm_enabled", False)
client = _client_with_db()
with (
patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
),
patch("app.api.v1.chat.get_parcel_context_for_chat", return_value=None),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "дай резюме"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["report_status"] == "ready"
assert f"250{_NBSP}000" in body["answer"]
def test_ask_parcel_context_error_does_not_break_chat(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Сбой чтения паспорта участка → чат не падает, отдаёт отчёт (обогащение опционально)."""
monkeypatch.setattr(settings, "llm_enabled", False)
client = _client_with_db()
with (
patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
),
patch(
"app.api.v1.chat.get_parcel_context_for_chat",
side_effect=RuntimeError("analyze read down"),
),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "дай резюме"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["report_status"] == "ready"
assert f"250{_NBSP}000" in body["answer"]
def test_ask_llm_disabled_no_provider_constructed(monkeypatch: pytest.MonkeyPatch) -> None:
"""llm_enabled=False (дефолт) → детерм. путь; провайдер НЕ конструируется (tripwire)."""
monkeypatch.setattr(settings, "llm_enabled", False)

View file

@ -0,0 +1,244 @@
"""Тесты read-only retrieval чата (#957): §22-отчёт + курируемый паспорт участка.
Покрываем `get_parcel_context_for_chat` (§19 data-residency):
строит ПЛОСКИЙ whitelist-дикт из фиксированных градо-полей analyze-рана;
НЕ протаскивает наружу сырые под-словари / геометрию / любой лишний ключ payload;
ЗОУИТ: dedup названий, cap, разрешение противоречия encumbranceoverlaps;
None, если analyze-рана/результата нет (эндпоинт работает как раньше).
Стратегия: latest_run_for патчится (никакой прод-БД); db MagicMock.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from app.services.chat import retrieval
_CAD = "66:41:0204016:10"
def _analyze_payload() -> dict[str, Any]:
"""analyze-1.0 result с курируемыми полями И «шумом», который наружу уходить НЕ должен."""
return {
"egrn": {
"address": "Екатеринбург, ул. Ленина, 1",
"area_m2": 5400,
"land_category": "земли населённых пунктов",
"permitted_use_text": "многоэтажная жилая застройка",
"cadastral_value_rub": 123456789,
"parcel_status": "учтённый",
"ownership_type": "муниципальная",
# Шум: сырой под-словарь + PII-подобные поля — НЕ должны попасть в срез.
"raw": {"owner_name": "Иванов И.И.", "internal_id": 42},
},
"nspd_zoning": {
"zone_code": "Ж-4",
"zone_name": "Зона многоэтажной жилой застройки",
"max_far": 2.5,
"max_floors": 25,
"max_height_m": 75,
"max_building_pct": 40,
"min_parcel_area_m2": 1500,
"regulation_source": "ПЗЗ Екатеринбурга",
"main_vri": ["2.6", "3.1"], # список ВРИ — в срез НЕ тянем (не в whitelist)
},
"encumbrance": {"has_zouit": True, "zouit_count": 2},
"nspd_zouit_overlaps": [
{"type_zone": "Приаэродромная территория", "reg_numb_border": "66:00-6.1"},
{"name": "Охранная зона ЛЭП", "coverage_pct": 12.3},
{"type_zone": "Приаэродромная территория"}, # дубль → dedup
],
# Тяжёлый сырой блоб + геометрия — НЕ курируемые, наружу НЕЛЬЗЯ.
"geometry": {"type": "Polygon", "coordinates": [[[1, 2], [3, 4]]]},
"market_now": {"deals": [{"price": 1}]},
}
def _make_run(result: Any, run_id: int = 7) -> MagicMock:
run = MagicMock()
run.result = result
run.id = run_id
return run
def _get_context(result: Any) -> dict[str, Any] | None:
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(result) if result is not None else None,
):
return retrieval.get_parcel_context_for_chat(MagicMock(), _CAD)
# ── whitelist: нужные поля есть, лишних нет ──────────────────────────────────────
def test_builds_curated_whitelist_dict() -> None:
ctx = _get_context(_analyze_payload())
assert ctx is not None
# ЕГРН-скаляры.
assert ctx["address"] == "Екатеринбург, ул. Ленина, 1"
assert ctx["area_m2"] == 5400
assert ctx["land_category"] == "земли населённых пунктов"
assert ctx["permitted_use_text"] == "многоэтажная жилая застройка"
assert ctx["cadastral_value_rub"] == 123456789
assert ctx["parcel_status"] == "учтённый"
assert ctx["ownership_type"] == "муниципальная"
# Градрегламент ПЗЗ.
assert ctx["zone_code"] == "Ж-4"
assert ctx["zone_name"] == "Зона многоэтажной жилой застройки"
assert ctx["max_far"] == 2.5
assert ctx["max_floors"] == 25
assert ctx["max_height_m"] == 75
assert ctx["max_building_pct"] == 40
assert ctx["min_parcel_area_m2"] == 1500
assert ctx["regulation_source"] == "ПЗЗ Екатеринбурга"
# ЗОУИТ-сводка + dedup названий.
assert ctx["has_zouit"] is True
assert ctx["zouit_count"] == 2
assert ctx["zouit_zone_names"] == ["Приаэродромная территория", "Охранная зона ЛЭП"]
def test_does_not_leak_geometry_raw_or_extra_keys() -> None:
"""§19: срез — ТОЛЬКО фиксированный whitelist; geometry/raw/market и т.п. НЕ протекают."""
ctx = _get_context(_analyze_payload())
assert ctx is not None
allowed = {
"address",
"area_m2",
"land_category",
"permitted_use_text",
"cadastral_value_rub",
"parcel_status",
"ownership_type",
"zone_code",
"zone_name",
"max_far",
"max_floors",
"max_height_m",
"max_building_pct",
"min_parcel_area_m2",
"regulation_source",
"has_zouit",
"zouit_count",
"zouit_zone_names",
}
assert set(ctx) <= allowed
# Явно: ни сырых под-словарей, ни геометрии, ни PII/ВРИ-списка.
assert "geometry" not in ctx
assert "raw" not in ctx
assert "market_now" not in ctx
assert "main_vri" not in ctx
# Все значения — скаляры или список строк (никаких вложенных dict/геометрии).
for key, value in ctx.items():
if key == "zouit_zone_names":
assert isinstance(value, list)
assert all(isinstance(n, str) for n in value)
else:
assert isinstance(value, str | int | float | bool)
# ── §19 type-guard: не-скаляры отбрасываются, а не протаскиваются ─────────────────
def test_dict_value_in_egrn_field_is_dropped() -> None:
"""Под-словарь в egrn.address (напр. с PII) НЕ должен утечь в срез — ключ отсутствует."""
payload = {
"egrn": {
"address": {"owner_pii": "Иванов И.И."}, # вложенный dict — §19-угроза
"area_m2": 500,
}
}
ctx = _get_context(payload)
assert ctx is not None
assert "address" not in ctx # dict-значение отброшено гейтом типа
assert ctx["area_m2"] == 500 # скаляр рядом при этом сохраняется
def test_list_with_nested_dict_in_zone_code_is_dropped() -> None:
"""Список (тем более с вложенным dict) в zone_code НЕ протаскивается — ключа нет."""
payload = {"nspd_zoning": {"zone_code": ["Ж-4", {"nested": "leak"}], "max_floors": 25}}
ctx = _get_context(payload)
assert ctx is not None
assert "zone_code" not in ctx # list-значение отброшено
assert ctx["max_floors"] == 25 # соседний скаляр цел
def test_zouit_names_drops_non_string_elements() -> None:
"""В списке ЗОУИТ не-строковые элементы (dict/число) отбрасываются, строки остаются."""
payload = {
"nspd_zouit_overlaps": [
{"type_zone": "Санитарно-защитная зона"},
{"type_zone": {"leak": "нельзя"}}, # dict вместо строки — отбросить
{"name": 12345}, # число вместо строки — отбросить
{"name": "Водоохранная зона"},
],
}
ctx = _get_context(payload)
assert ctx is not None
assert ctx["zouit_zone_names"] == ["Санитарно-защитная зона", "Водоохранная зона"]
# Ни одного не-строкового элемента / repr'а вложенной структуры.
assert all(isinstance(n, str) for n in ctx["zouit_zone_names"])
assert "leak" not in " ".join(ctx["zouit_zone_names"])
# ── зона: fallback zone_code → regulation_zone_index ─────────────────────────────
def test_zone_code_falls_back_to_regulation_zone_index() -> None:
payload = {"nspd_zoning": {"regulation_zone_index": "ОД-1"}}
ctx = _get_context(payload)
assert ctx is not None
assert ctx["zone_code"] == "ОД-1"
# ── ЗОУИТ: противоречие encumbrance=нет, но пересечения есть ──────────────────────
def test_zouit_contradiction_resolved_from_overlaps() -> None:
payload = {
"encumbrance": {"has_zouit": False, "zouit_count": 0},
"nspd_zouit_overlaps": [
{"type_zone": "Санитарно-защитная зона"},
{"name": "Водоохранная зона"},
],
}
ctx = _get_context(payload)
assert ctx is not None
assert ctx["has_zouit"] is True
assert ctx["zouit_count"] == 2
assert ctx["zouit_zone_names"] == ["Санитарно-защитная зона", "Водоохранная зона"]
def test_zouit_names_capped() -> None:
payload = {
"nspd_zouit_overlaps": [{"type_zone": f"Зона {i}"} for i in range(30)],
}
ctx = _get_context(payload)
assert ctx is not None
assert len(ctx["zouit_zone_names"]) == retrieval._MAX_ZOUIT_NAMES
# ── graceful: отсутствие полей / рана ────────────────────────────────────────────
def test_missing_fields_are_omitted_not_none() -> None:
# Пустой ЕГРН/зонирование → эти ключи просто отсутствуют (не None).
ctx = _get_context({"egrn": {"address": "Тест"}})
assert ctx == {"address": "Тест"}
assert "zone_code" not in ctx
assert "area_m2" not in ctx
def test_returns_none_when_no_run() -> None:
assert _get_context(None) is None
def test_returns_none_when_result_not_dict() -> None:
assert _get_context(["not", "a", "dict"]) is None
def test_returns_none_when_no_curated_fields() -> None:
# Ран есть, но ни одного курируемого поля → None (нечего вливать в отчёт).
assert _get_context({"market_now": {"x": 1}, "geometry": {}}) is None

View file

@ -20,6 +20,7 @@ _EXPECTED_TOOLS = {
"get_forecast",
"get_risks",
"get_scenarios",
"get_parcel_info",
}
@ -39,15 +40,23 @@ def _report() -> dict[str, Any]:
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
"confidence": {"level": "medium", "factors": {"deals": "ok"}},
"scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
"parcel_context": {
"address": "Екатеринбург, ул. Ленина, 1",
"zone_code": "Ж-4",
"zone_name": "Зона многоэтажной застройки",
"max_floors": 25,
"has_zouit": True,
"zouit_zone_names": ["Приаэродромная территория"],
},
}
# ── Спеки ───────────────────────────────────────────────────────────────────────
def test_tool_specs_cover_all_five() -> None:
def test_tool_specs_cover_all_six() -> None:
specs = tools.tool_specs()
assert len(specs) == 5
assert len(specs) == 6
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
@ -99,6 +108,20 @@ def test_scenarios_slice() -> None:
assert set(out["by_scenario"]) == {"base", "conservative"}
def test_parcel_info_slice_is_verbatim() -> None:
out = tools.get_parcel_info(_report())
assert out["zone_code"] == "Ж-4"
assert out["zone_name"] == "Зона многоэтажной застройки"
assert out["max_floors"] == 25
assert out["zouit_zone_names"] == ["Приаэродромная территория"]
def test_parcel_info_missing_returns_marker() -> None:
# Паспорт участка не влит (analyze-рана не было) → маркер «недоступно», не KeyError.
assert tools.get_parcel_info({}) == {"available": False}
assert tools.get_parcel_info({"parcel_context": {}}) == {"available": False}
# ── Robust: отсутствующая/пустая секция → маркер, НЕ KeyError ─────────────────────
@ -136,6 +159,7 @@ def test_execute_unknown_tool_returns_marker_not_raise() -> None:
def test_sections_for_tool_provenance() -> None:
assert tools.sections_for_tool("get_exec_summary") == ("exec_summary",)
assert tools.sections_for_tool("get_risks") == ("scoring", "confidence")
assert tools.sections_for_tool("get_parcel_info") == ("parcel_context",)
assert tools.sections_for_tool("get_nonexistent") == ()

View file

@ -29,11 +29,11 @@ def test_render_known_template() -> None:
assert "ассистент" in out
def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
"""chat_system@v2 (#957 fix): чистый рендер квартирографии, без «N-тип» склейки."""
def test_chat_system_v3_has_unit_mix_formatting_rule() -> None:
"""chat_system@v3: чистый рендер квартирографии, без «N-тип» склейки (сохранено с v2)."""
tpl = get_template("chat_system")
assert tpl.version == 2
assert tpl.key == "chat_system@v2"
assert tpl.version == 3
assert tpl.key == "chat_system@v3"
out = render("chat_system")
assert "квартирограф" in out.lower() # правило про перечисление сегментов
assert "«2-1-к»" in out # явный анти-пример (склейка номера с типом)
@ -42,6 +42,13 @@ def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
assert "ВЕРБАТИМ" in out
def test_chat_system_v3_mentions_parcel_info_tool() -> None:
"""chat_system@v3: паспорт участка/ПЗЗ теперь в области ответа через get_parcel_info."""
out = render("chat_system")
assert "get_parcel_info" in out # tool паспорта участка/градрегламента
assert "зон" in out.lower() # тер.зона ПЗЗ упомянута как отвечаемая
def test_render_with_required_vars() -> None:
"""render подставляет переменные через str.format и валидирует required_vars."""
tpl = PromptTemplate(