diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index 74be8b20..05ec4006 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -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], diff --git a/backend/app/services/chat/retrieval.py b/backend/app/services/chat/retrieval.py index afeb7f1c..c6832eed 100644 --- a/backend/app/services/chat/retrieval.py +++ b/backend/app/services/chat/retrieval.py @@ -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 diff --git a/backend/app/services/chat/safe_payload.py b/backend/app/services/chat/safe_payload.py index da058c49..44966bc1 100644 --- a/backend/app/services/chat/safe_payload.py +++ b/backend/app/services/chat/safe_payload.py @@ -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. diff --git a/backend/app/services/chat/tools.py b/backend/app/services/chat/tools.py index 723a2fb3..7c18ba5d 100644 --- a/backend/app/services/chat/tools.py +++ b/backend/app/services/chat/tools.py @@ -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", diff --git a/backend/app/services/llm/prompts.py b/backend/app/services/llm/prompts.py index d512e527..850dc98b 100644 --- a/backend/app/services/llm/prompts.py +++ b/backend/app/services/llm/prompts.py @@ -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+ м²) и долю в % если она есть. " "НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис " diff --git a/backend/tests/api/v1/test_chat.py b/backend/tests/api/v1/test_chat.py index 8b32b49a..4b13f087 100644 --- a/backend/tests/api/v1/test_chat.py +++ b/backend/tests/api/v1/test_chat.py @@ -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) diff --git a/backend/tests/services/chat/test_retrieval.py b/backend/tests/services/chat/test_retrieval.py new file mode 100644 index 00000000..b3a97ebc --- /dev/null +++ b/backend/tests/services/chat/test_retrieval.py @@ -0,0 +1,244 @@ +"""Тесты read-only retrieval чата (#957): §22-отчёт + курируемый паспорт участка. + +Покрываем `get_parcel_context_for_chat` (§19 data-residency): + • строит ПЛОСКИЙ whitelist-дикт из фиксированных градо-полей analyze-рана; + • НЕ протаскивает наружу сырые под-словари / геометрию / любой лишний ключ payload; + • ЗОУИТ: dedup названий, cap, разрешение противоречия encumbrance↔overlaps; + • 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 diff --git a/backend/tests/services/chat/test_tools.py b/backend/tests/services/chat/test_tools.py index d67a626e..61791d6f 100644 --- a/backend/tests/services/chat/test_tools.py +++ b/backend/tests/services/chat/test_tools.py @@ -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") == () diff --git a/backend/tests/services/llm/test_prompts.py b/backend/tests/services/llm/test_prompts.py index 8ab2c3cc..00e6dfac 100644 --- a/backend/tests/services/llm/test_prompts.py +++ b/backend/tests/services/llm/test_prompts.py @@ -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(