gendesign/backend/tests/services/chat/test_retrieval.py
bot-backend 57d156973e
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 2m46s
Deploy / deploy (push) Successful in 1m31s
feat(chat): tool get_parcel_info — курируемый градрегламент/ЗОУИТ/ЕГРН в контекст чата (#2366)
2026-07-04 01:24:42 +00: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