feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата (#2366)
This commit is contained in:
parent
38f703c8ec
commit
57d156973e
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.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
|
||||||
from app.services.chat.intents import render_answer, route_intent
|
from app.services.chat.intents import render_answer, route_intent
|
||||||
from app.services.chat.orchestrator import orchestrate_chat
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -92,11 +96,46 @@ async def ask(
|
||||||
report_status="pending",
|
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:
|
if settings.llm_enabled:
|
||||||
return await _answer_via_llm(db, payload, report, run_id)
|
return await _answer_via_llm(db, payload, report, run_id)
|
||||||
return _answer_deterministic(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(
|
def _answer_deterministic(
|
||||||
payload: ChatAskRequest,
|
payload: ChatAskRequest,
|
||||||
report: dict[str, Any],
|
report: dict[str, Any],
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
||||||
# схеме явный и устойчивый.
|
# схеме явный и устойчивый.
|
||||||
_FORECAST_SCHEMA_VERSION = "1.0"
|
_FORECAST_SCHEMA_VERSION = "1.0"
|
||||||
|
|
||||||
|
# Максимум названий ЗОУИТ-зон в курируемом срезе (§19: ограничиваем объём, чтобы наружу
|
||||||
|
# не ушёл длинный «хвост» — сводки хватает списка из первых нескольких зон).
|
||||||
|
_MAX_ZOUIT_NAMES = 15
|
||||||
|
|
||||||
|
|
||||||
def get_report_for_chat(
|
def get_report_for_chat(
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|
@ -57,3 +61,116 @@ def get_report_for_chat(
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
return result, run.id
|
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``:
|
РАЗРЕШЕНО в ``section_data`` / ``fields``:
|
||||||
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
• срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
|
||||||
scoring / confidence / scenarios) — это посчитанные advisory-агрегаты.
|
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;
|
• свободный insight-текст / лиды / любой контент с PII;
|
||||||
• любой источник, помеченный confidential.
|
• любой источник, помеченный confidential.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
|
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_exec_summary` / `get_product_recommendation` / `get_forecast` /
|
||||||
`get_risks` / `get_scenarios`). Без параметров — каждый отдаёт фиксированную
|
`get_risks` / `get_scenarios` / `get_parcel_info`). Без параметров — каждый отдаёт
|
||||||
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
|
фиксированную секцию(и) отчёта (модель не управляет вычислениями, только запрашивает
|
||||||
|
данные).
|
||||||
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
|
||||||
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
|
||||||
НИКАКОЙ движковой математики — только срез готового dict'а.
|
НИКАКОЙ движковой математики — только срез готового dict'а.
|
||||||
|
|
@ -73,6 +74,16 @@ def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
|
||||||
return _section(report, "scenarios")
|
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) ─
|
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
|
||||||
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
|
# ЕДИНЫЙ источник истины: и спеки, и 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_forecast": (get_forecast, ("future_market",)),
|
||||||
"get_risks": (get_risks, ("scoring", "confidence")),
|
"get_risks": (get_risks, ("scoring", "confidence")),
|
||||||
"get_scenarios": (get_scenarios, ("scenarios",)),
|
"get_scenarios": (get_scenarios, ("scenarios",)),
|
||||||
|
"get_parcel_info": (get_parcel_info, ("parcel_context",)),
|
||||||
}
|
}
|
||||||
|
|
||||||
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
|
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
|
||||||
|
|
@ -103,8 +115,11 @@ _TOOL_DESCRIPTIONS: dict[str, str] = {
|
||||||
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
|
"Риски участка: специальные индексы §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]]:
|
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]
|
return [_spec(name) for name in _TOOLS]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -161,6 +176,7 @@ __all__ = [
|
||||||
"execute_tool",
|
"execute_tool",
|
||||||
"get_exec_summary",
|
"get_exec_summary",
|
||||||
"get_forecast",
|
"get_forecast",
|
||||||
|
"get_parcel_info",
|
||||||
"get_product_recommendation",
|
"get_product_recommendation",
|
||||||
"get_risks",
|
"get_risks",
|
||||||
"get_scenarios",
|
"get_scenarios",
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,24 @@ _SYSTEM_BASE = PromptTemplate(
|
||||||
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
|
||||||
_CHAT_SYSTEM = PromptTemplate(
|
_CHAT_SYSTEM = PromptTemplate(
|
||||||
name="chat_system",
|
name="chat_system",
|
||||||
version=2,
|
version=3,
|
||||||
template=(
|
template=(
|
||||||
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
|
||||||
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
|
||||||
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
"ЖЁСТКИЕ ПРАВИЛА:\n"
|
||||||
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
|
||||||
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
|
"Чтобы получить нужные числа, вызови подходящий инструмент. Для вопросов про сам "
|
||||||
|
"участок — адрес, площадь, категорию земель, ВРИ, территориальную зону ПЗЗ и её "
|
||||||
|
"код/название, лимиты застройки, ЗОУИТ-обременения — вызови get_parcel_info.\n"
|
||||||
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
|
||||||
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
|
||||||
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
"отчёте нет. Не подставляй правдоподобные значения.\n"
|
||||||
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
|
||||||
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
|
||||||
"инвестиционного решения. Не давай гарантий доходности.\n"
|
"инвестиционного решения. Не давай гарантий доходности.\n"
|
||||||
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
|
"5. Вопросы, выходящие за рамки данных отчёта и паспорта участка (сравнение с другими "
|
||||||
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
|
"участками, юридические заключения, получение разрешений/согласований) — вежливо "
|
||||||
"области отчёта, и предложи вопросы по самому форсайту.\n"
|
"скажи, что это вне области отчёта, и предложи вопросы по участку и его форсайту.\n"
|
||||||
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
"6. При перечислении квартирографии / сегментов указывай ТИП понятно "
|
||||||
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
"(студия, 1-к, 2-к, 3-к, евро-форматы, 80+ м²) и долю в % если она есть. "
|
||||||
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
"НЕ нумеруй порядковыми номерами и НЕ склеивай номер с типом через дефис "
|
||||||
|
|
|
||||||
|
|
@ -68,9 +68,7 @@ def _report() -> dict[str, Any]:
|
||||||
"summary": "Разброс умеренный.",
|
"summary": "Разброс умеренный.",
|
||||||
},
|
},
|
||||||
"scoring": {
|
"scoring": {
|
||||||
"special_indices": {
|
"special_indices": {"indices": {"cannibalization": {"value": 0.31, "label": None}}},
|
||||||
"indices": {"cannibalization": {"value": 0.31, "label": None}}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"confidence": {
|
"confidence": {
|
||||||
"level": "medium",
|
"level": "medium",
|
||||||
|
|
@ -454,6 +452,130 @@ def test_ask_llm_enabled_degrades_to_deterministic_on_failure(
|
||||||
assert f"250{_NBSP}000" in body["answer"]
|
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:
|
def test_ask_llm_disabled_no_provider_constructed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
"""llm_enabled=False (дефолт) → детерм. путь; провайдер НЕ конструируется (tripwire)."""
|
"""llm_enabled=False (дефолт) → детерм. путь; провайдер НЕ конструируется (tripwire)."""
|
||||||
monkeypatch.setattr(settings, "llm_enabled", False)
|
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_forecast",
|
||||||
"get_risks",
|
"get_risks",
|
||||||
"get_scenarios",
|
"get_scenarios",
|
||||||
|
"get_parcel_info",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,15 +40,23 @@ def _report() -> dict[str, Any]:
|
||||||
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
|
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
|
||||||
"confidence": {"level": "medium", "factors": {"deals": "ok"}},
|
"confidence": {"level": "medium", "factors": {"deals": "ok"}},
|
||||||
"scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
|
"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()
|
specs = tools.tool_specs()
|
||||||
assert len(specs) == 5
|
assert len(specs) == 6
|
||||||
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
|
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"}
|
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 ─────────────────────
|
# ── Robust: отсутствующая/пустая секция → маркер, НЕ KeyError ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -136,6 +159,7 @@ def test_execute_unknown_tool_returns_marker_not_raise() -> None:
|
||||||
def test_sections_for_tool_provenance() -> None:
|
def test_sections_for_tool_provenance() -> None:
|
||||||
assert tools.sections_for_tool("get_exec_summary") == ("exec_summary",)
|
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_risks") == ("scoring", "confidence")
|
||||||
|
assert tools.sections_for_tool("get_parcel_info") == ("parcel_context",)
|
||||||
assert tools.sections_for_tool("get_nonexistent") == ()
|
assert tools.sections_for_tool("get_nonexistent") == ()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,11 @@ def test_render_known_template() -> None:
|
||||||
assert "ассистент" in out
|
assert "ассистент" in out
|
||||||
|
|
||||||
|
|
||||||
def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
|
def test_chat_system_v3_has_unit_mix_formatting_rule() -> None:
|
||||||
"""chat_system@v2 (#957 fix): чистый рендер квартирографии, без «N-тип» склейки."""
|
"""chat_system@v3: чистый рендер квартирографии, без «N-тип» склейки (сохранено с v2)."""
|
||||||
tpl = get_template("chat_system")
|
tpl = get_template("chat_system")
|
||||||
assert tpl.version == 2
|
assert tpl.version == 3
|
||||||
assert tpl.key == "chat_system@v2"
|
assert tpl.key == "chat_system@v3"
|
||||||
out = render("chat_system")
|
out = render("chat_system")
|
||||||
assert "квартирограф" in out.lower() # правило про перечисление сегментов
|
assert "квартирограф" in out.lower() # правило про перечисление сегментов
|
||||||
assert "«2-1-к»" in out # явный анти-пример (склейка номера с типом)
|
assert "«2-1-к»" in out # явный анти-пример (склейка номера с типом)
|
||||||
|
|
@ -42,6 +42,13 @@ def test_chat_system_v2_has_unit_mix_formatting_rule() -> None:
|
||||||
assert "ВЕРБАТИМ" in out
|
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:
|
def test_render_with_required_vars() -> None:
|
||||||
"""render подставляет переменные через str.format и валидирует required_vars."""
|
"""render подставляет переменные через str.format и валидирует required_vars."""
|
||||||
tpl = PromptTemplate(
|
tpl = PromptTemplate(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue