gendesign/backend/tests/api/v1/test_chat.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

599 lines
25 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""Тесты эндпоинта чата `POST /api/v1/chat/ask` (#957, Step 1, ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM).
Покрывает:
• Каждый intent отдаёт корректный шаблонный RU-ответ для фикстуры-отчёта.
• Нет рана → report_status='pending' + RU-подсказка «запустите анализ» (READ-ONLY).
• unknown-вопрос → меню тем (fallback_reason='intent_unknown').
• llm_used всегда False; grounded_in несёт run_id/schema_version/sections.
• 401 без X-Authenticated-User (mini-RBAC-app, как test_rbac.py — реальный app в
тестах бьётся мимо Caddy с settings.testing=True, поэтому RBAC проверяем на копии
middleware).
Стратегия: DB — через app.dependency_overrides[get_db] (MagicMock-сессия); сам отчёт
контролируем патчем latest_run_for (никакой прод-БД).
"""
from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from fastapi.testclient import TestClient
from app.api.v1 import chat as chat_router
from app.core import auth as auth_mod
from app.core.config import settings
from app.core.db import get_db
from app.main import app
from app.services.llm.provider import LLMProvider, ProviderResponse, ToolCall
_CAD = "66:41:0204016:10"
# Разделитель тысяч — неразрывный пробел (зеркало ru-locale, _NBSP в intents.py).
_NBSP = " "
# ── Фикстуры ─────────────────────────────────────────────────────────────────────
def _report() -> dict[str, Any]:
"""Полный отчёт (форма SiteFinderReport.as_dict()) с узнаваемыми числами."""
return {
"schema_version": "1.0",
"advisory": True,
"exec_summary": {
"headline": "Комфорт-класс, 2-3-комнатные",
"verdict": "Участок подходит под комфорт-класс.",
"key_numbers": {"цена_руб_м2": 250000},
"overall_confidence": "medium",
},
"market_now": {"summary": "Рынок активен."},
"future_market": {
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
"future_supply": {"pressure": 0.4},
"summary": "Ожидается дефицит на горизонте 12 мес.",
},
"product_tz": {
"obj_class": "комфорт",
"mix": [{"fmt": "2k"}],
"reasons": [{"why": "дефицит формата"}],
"summary": "Рекомендуется комфорт-класс.",
},
"scenarios": {
"by_scenario": {"conservative": {}, "base": {}, "aggressive": {}},
"summary": "Разброс умеренный.",
},
"scoring": {
"special_indices": {"indices": {"cannibalization": {"value": 0.31, "label": None}}},
},
"confidence": {
"level": "medium",
"rationale": "Прогноз спроса — прокси.",
"factors": {"deals": "ok"},
},
"meta": {"cad_num": _CAD, "schema_version": "1.0"},
}
def _make_run(report: dict[str, Any], run_id: int = 42) -> MagicMock:
"""Замокать Row из latest_run_for (.result — отчёт, .id — run_id)."""
run = MagicMock()
run.result = report
run.id = run_id
return run
def _client_with_db() -> TestClient:
"""TestClient с override get_db на MagicMock-сессию (реальная БД не трогается)."""
app.dependency_overrides[get_db] = lambda: MagicMock()
return TestClient(app)
# ── Happy-path: intents возвращают шаблонный ответ ───────────────────────────────
@pytest.mark.parametrize(
("message", "expected_substrings", "expected_sections"),
[
("Дай резюме по участку", ["Комфорт-класс", f"250{_NBSP}000"], ["exec_summary"]),
("Что здесь строить?", ["комфорт"], ["exec_summary", "product_tz"]),
("Почему такой прогноз?", ["горизонт", "прокси"], ["future_market"]),
("Какие риски?", ["каннибализация портфеля", "0,31"], ["scoring"]),
("Покажи сценарии", ["консервативный", "базовый"], ["scenarios"]),
],
)
def test_ask_intent_returns_templated_answer(
message: str,
expected_substrings: list[str],
expected_sections: list[str],
) -> None:
"""Каждый intent → корректный RU-ответ; числа из отчёта; grounded_in заполнен."""
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": message},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["report_status"] == "ready"
assert body["llm_used"] is False
assert body["advisory"] is True
for sub in expected_substrings:
assert sub in body["answer"], f"{sub!r} not in answer: {body['answer']}"
assert body["grounded_in"]["run_id"] == 42
assert body["grounded_in"]["schema_version"] == "1.0"
for sect in expected_sections:
assert sect in body["grounded_in"]["sections"]
def test_ask_explicit_intent_is_respected() -> None:
"""Явный intent уважается поверх текста (риск-текст, но intent=summary)."""
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "какие риски?", "intent": "summary"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["grounded_in"]["sections"] == ["exec_summary"]
def test_ask_answer_always_has_advisory_caveat() -> None:
"""Каждый ответ заканчивается advisory-оговоркой."""
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "резюме"},
)
assert "советующий характер" in resp.json()["answer"]
def test_ask_history_accepted_but_unused() -> None:
"""history принимается (200), но в Step 1 не влияет на ответ (llm_used=False)."""
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={
"cad_num": _CAD,
"message": "резюме",
"history": [{"role": "user", "content": "привет"}],
},
)
assert resp.status_code == 200, resp.text
assert resp.json()["llm_used"] is False
# ── unknown intent → меню ────────────────────────────────────────────────────────
def test_ask_unknown_intent_returns_menu() -> None:
"""Нераспознанный вопрос → меню тем + fallback_reason='intent_unknown'."""
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
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 body["fallback_reason"] == "intent_unknown"
assert body["grounded_in"]["sections"] == []
assert "что здесь строить" in body["answer"].lower()
# ── pending: рана нет ────────────────────────────────────────────────────────────
def test_ask_no_run_returns_pending() -> None:
"""Рана нет → report_status='pending' + RU-подсказка «запустите анализ»."""
client = _client_with_db()
with patch("app.services.chat.retrieval.latest_run_for", 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"] == "pending"
assert body["grounded_in"] is None
assert body["llm_used"] is False
assert body["fallback_reason"] == "report_pending"
assert "Запустите анализ участка" in body["answer"]
def test_ask_report_read_bridged_through_threadpool(monkeypatch: pytest.MonkeyPatch) -> None:
"""#1245: синхронное чтение §22-отчёта мостится через run_in_threadpool (не в loop).
`get_report_for_chat` — sync SQLAlchemy (тянет крупный JSONB) и НЕ должен блокировать
event loop. Оборачиваем `run_in_threadpool` в шпион: проверяем, что чтение отчёта
реально диспатчится через него (как и синхронный LLM `complete` в _answer_via_llm).
"""
from app.services.chat import retrieval as retrieval_mod
dispatched: list[str] = []
real_run_in_threadpool = chat_router.run_in_threadpool
async def _spy(func: Any, *args: Any, **kwargs: Any) -> Any:
dispatched.append(getattr(func, "__name__", repr(func)))
return await real_run_in_threadpool(func, *args, **kwargs)
monkeypatch.setattr(chat_router, "run_in_threadpool", _spy)
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "резюме"},
)
assert resp.status_code == 200, resp.text
# Синхронное чтение отчёта прошло через threadpool (имя sync-функции зафиксировано).
assert retrieval_mod.get_report_for_chat.__name__ in dispatched
def test_ask_db_error_returns_pending_not_500() -> None:
"""Сбой БД на read-only → pending (не 500), клиент может повторить."""
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
side_effect=RuntimeError("db 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"] == "pending"
assert body["fallback_reason"] == "report_unavailable"
# ── Валидация запроса ────────────────────────────────────────────────────────────
def test_ask_missing_message_422() -> None:
"""message обязателен → 422."""
client = _client_with_db()
resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD})
assert resp.status_code == 422
def test_ask_empty_cad_num_422() -> None:
"""Пустой cad_num → 422 (min_length=1)."""
client = _client_with_db()
resp = client.post("/api/v1/chat/ask", json={"cad_num": "", "message": "резюме"})
assert resp.status_code == 422
# ── RBAC: 401 без X-Authenticated-User ───────────────────────────────────────────
# Реальный app в тестах бьётся мимо Caddy с settings.testing=True → rbac_guard
# пропускает. RBAC проверяем на КОПИИ middleware (как tests/test_rbac.py), смонтировав
# тот же chat-роутер под /api/v1/chat.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
def _build_rbac_test_app() -> FastAPI:
"""FastAPI с копией rbac_guard из app/main.py + смонтированным chat-роутером."""
rbac_app = FastAPI()
@rbac_app.middleware("http")
async def rbac_guard( # type: ignore[unused-ignore]
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS:
return await call_next(request)
username = request.headers.get("X-Authenticated-User")
if not username:
return JSONResponse(
status_code=401,
content={"detail": "no authenticated user (Caddy basic_auth required)"},
)
try:
role = auth_mod.get_role(username)
except KeyError:
return JSONResponse(
status_code=403,
content={"detail": "user not in roles config"},
)
if _ADMIN_API_RE.match(path) and role != "admin":
return JSONResponse(status_code=403, content={"detail": "admin only"})
return await call_next(request)
rbac_app.include_router(chat_router.router, prefix="/api/v1/chat", tags=["chat"])
rbac_app.dependency_overrides[get_db] = lambda: MagicMock()
return rbac_app
def test_ask_no_auth_header_returns_401() -> None:
"""Без X-Authenticated-User → 401 (rbac_guard, чат смонтирован под /api/v1)."""
client = TestClient(_build_rbac_test_app())
resp = client.post("/api/v1/chat/ask", json={"cad_num": _CAD, "message": "резюме"})
assert resp.status_code == 401
assert "no authenticated user" in resp.json()["detail"]
def test_ask_unknown_user_returns_403() -> None:
"""Юзер не в roles.yaml → 403 (rbac_guard)."""
client = TestClient(_build_rbac_test_app())
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "резюме"},
headers={"X-Authenticated-User": "nobody-unknown"},
)
assert resp.status_code == 403
def test_ask_known_user_passes_rbac() -> None:
"""Известный юзер (admin) проходит rbac_guard → доходит до хендлера (200)."""
client = TestClient(_build_rbac_test_app())
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
resp = client.post(
"/api/v1/chat/ask",
json={"cad_num": _CAD, "message": "резюме"},
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200, resp.text
# ── Step 2: ветка llm_enabled (LLM tool-loop через эндпоинт) ─────────────────────
# Сеть НЕ дёргается: подменяем _build_default_provider на fake (provider=None путь в
# complete), чтобы проверить именно бридж эндпоинта (run_in_threadpool → orchestrate).
class _FakeProvider(LLMProvider):
"""Внешний fake провайдера: 1-й вызов — tool-call, 2-й — финальная проза."""
def __init__(self) -> None:
self.calls = 0
@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="c1", name="get_exec_summary", arguments="{}")],
model="gpt-4o-mini",
)
return ProviderResponse(content="LLM-проза по участку.", model="gpt-4o-mini")
def test_ask_llm_enabled_uses_orchestrator(monkeypatch: pytest.MonkeyPatch) -> None:
"""llm_enabled=True → tool-loop: llm_used=True, ответ LLM, grounded_in.sections верны."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
# ОДИН инстанс на запрос: его stateful .calls ведёт tool-call → проза между
# витками loop'а (новый инстанс на каждый виток сбросил бы счётчик).
fake = _FakeProvider()
monkeypatch.setattr("app.services.llm.client._build_default_provider", lambda: fake)
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
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 body["llm_used"] is True
assert body["answer"] == "LLM-проза по участку."
assert body["grounded_in"]["sections"] == ["exec_summary"]
assert body["grounded_in"]["run_id"] == 42
def test_ask_llm_enabled_degrades_to_deterministic_on_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""llm_enabled=True, но провайдера нет (ключ None) → детерм. fallback, llm_used=False."""
monkeypatch.setattr(settings, "llm_enabled", True)
monkeypatch.setattr(settings, "openai_api_key", None)
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
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 False
assert body["fallback_reason"] == "no_api_key"
# Детерминированный Step-1 ответ с числом отчёта (вербатим).
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)
def _tripwire() -> None:
raise AssertionError("provider must NOT be built when llm_enabled=False")
monkeypatch.setattr("app.services.llm.client._build_default_provider", _tripwire)
client = _client_with_db()
with patch(
"app.services.chat.retrieval.latest_run_for",
return_value=_make_run(_report()),
):
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 False
assert f"250{_NBSP}000" in body["answer"]