gendesign/backend/tests/services/chat/test_tools.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

175 lines
7 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.

"""Тесты секционных tool'ов чата (#957, Step 2) — PURE срезы report_dict, без БД/recompute.
Покрываем:
• tool_specs: 5 спек, валидная OpenAI-форма, без параметров.
• executors: достают нужную секцию ВЕРБАТИМ; числа не пересчитываются.
• robust: отсутствует секция → маркер «недоступно», НЕ KeyError.
• execute_tool/sections_for_tool: реестр имя→executor + provenance-секции.
• неизвестное имя tool'а: безопасный маркер + пустые секции (не падаем).
"""
from __future__ import annotations
from typing import Any
from app.services.chat import tools
_EXPECTED_TOOLS = {
"get_exec_summary",
"get_product_recommendation",
"get_forecast",
"get_risks",
"get_scenarios",
"get_parcel_info",
}
def _report() -> dict[str, Any]:
"""Узнаваемый отчёт (форма SiteFinderReport.as_dict())."""
return {
"exec_summary": {
"headline": "Комфорт-класс",
"key_numbers": {"цена_руб_м2": 250000},
"overall_confidence": "medium",
},
"future_market": {
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
"future_supply": {"pressure": 0.4},
},
"product_tz": {"obj_class": "комфорт", "mix": [{"fmt": "2k"}]},
"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_six() -> None:
specs = tools.tool_specs()
assert len(specs) == 6
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
def test_tool_specs_are_valid_openai_function_shape() -> None:
for spec in tools.tool_specs():
assert spec["type"] == "function"
fn = spec["function"]
assert isinstance(fn["name"], str) and fn["name"]
assert isinstance(fn["description"], str) and fn["description"]
params = fn["parameters"]
assert params["type"] == "object"
# Секционные tool'ы без аргументов (модель не управляет вычислениями).
assert params["properties"] == {}
assert params["additionalProperties"] is False
# ── Executors: верные срезы, числа вербатим ──────────────────────────────────────
def test_exec_summary_slice_is_verbatim() -> None:
out = tools.get_exec_summary(_report())
assert out["headline"] == "Комфорт-класс"
# Число берётся как есть, не пересчитывается.
assert out["key_numbers"]["цена_руб_м2"] == 250000
def test_product_recommendation_slice() -> None:
out = tools.get_product_recommendation(_report())
assert out["obj_class"] == "комфорт"
assert out["mix"] == [{"fmt": "2k"}]
def test_forecast_slice() -> None:
out = tools.get_forecast(_report())
assert len(out["forecasts_by_horizon"]) == 2
assert out["future_supply"]["pressure"] == 0.4
def test_risks_merges_scoring_and_confidence() -> None:
out = tools.get_risks(_report())
assert set(out) == {"scoring", "confidence"}
indices = out["scoring"]["special_indices"]["indices"]
assert indices["cannibalization"]["value"] == 0.31
assert out["confidence"]["level"] == "medium"
def test_scenarios_slice() -> None:
out = tools.get_scenarios(_report())
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 ─────────────────────
def test_missing_section_returns_not_available_marker() -> None:
assert tools.get_exec_summary({}) == {"available": False}
assert tools.get_forecast({}) == {"available": False}
assert tools.get_scenarios({}) == {"available": False}
def test_risks_robust_to_missing_subsections() -> None:
out = tools.get_risks({})
assert out == {
"scoring": {"available": False},
"confidence": {"available": False},
}
def test_empty_section_dict_treated_as_unavailable() -> None:
# Пустой dict секции → маркер (нечего показывать), а не «{}» как валидные данные.
assert tools.get_product_recommendation({"product_tz": {}}) == {"available": False}
# ── Реестр: execute_tool / sections_for_tool / is_known_tool ─────────────────────
def test_execute_tool_dispatches_by_name() -> None:
out = tools.execute_tool("get_exec_summary", _report())
assert out["headline"] == "Комфорт-класс"
def test_execute_unknown_tool_returns_marker_not_raise() -> None:
assert tools.execute_tool("get_nonexistent", _report()) == {"available": False}
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") == ()
def test_is_known_tool() -> None:
assert tools.is_known_tool("get_forecast") is True
assert tools.is_known_tool("nope") is False
def test_executors_do_not_mutate_report() -> None:
report = _report()
before = dict(report["exec_summary"])
tools.get_exec_summary(report)
assert report["exec_summary"] == before