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

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

244 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты read-only 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