gendesign/backend/tests/api/v1/test_chat.py
Light1YT 4847a2eb01
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / backend-tests (pull_request) Successful in 6m23s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m43s
Deploy / deploy (push) Successful in 1m38s
Deploy / build-worker (push) Successful in 7m39s
feat(chat): deterministic §22 chat foundation — POST /api/v1/chat/ask (no LLM)
Step 1 of #957. Answers parcel questions by reading the already-persisted
§22 SiteFinderReport (latest_run_for, schema "1.0", read-only) and returning
templated RU answers with engine numbers verbatim (§16, never fabricated).
Intent routing (explicit or RU keyword match) -> per-section renderers;
graceful on partial/missing sections and pending (no run / DB error) without
500s. Works with llm_enabled=False (llm_used always False); LLM composition
is Step 2. Mounted off /api/v1/chat so rbac_guard auto-requires an authed
known user.

Refs #957
2026-06-08 17:21:51 +05:00

336 lines
14 KiB
Python
Raw 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.db import get_db
from app.main import app
_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_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