feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата #2366
9 changed files with 599 additions and 23 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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+ м²) и долю в % если она есть. "
|
||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
244
backend/tests/services/chat/test_retrieval.py
Normal file
244
backend/tests/services/chat/test_retrieval.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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") == ()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue