All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m41s
CI / backend-tests (push) Successful in 6m24s
CI / backend-tests (pull_request) Successful in 6m23s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m12s
Add the LLM prose-composition path for the parcel-forecast chat, layered over the deterministic Step-1 fallback which stays the safety net. - chat/tools.py: 5 read-only section tools (exec_summary, product_recommendation, forecast, risks, scenarios) — pure slices of the loaded report dict, no DB/ recompute, graceful on missing sections. market_now (raw analyze blob) and meta are deliberately NOT exposed -> highest-PII data cannot reach the LLM. - chat/safe_payload.py: the §19 gate — single place that builds the outbound SafePayload from a section-aggregate allowlist; honors is_confidential hard-block. - chat/orchestrator.py: manual tool-call loop with call-cap/termination, real grounded_in provenance; any LLMResult.ok=False (disabled/timeout/rate_limited/ redaction_refused/call_cap/provider_error/empty) degrades to the deterministic answer. - llm/prompts.py: versioned chat_system@v1 — answer only from sections, never fabricate numbers, advisory tone, decline out-of-scope. - api/v1/chat.py: branch on settings.llm_enabled; sync complete bridged via run_in_threadpool. Default-off -> deterministic path, no provider built. - Tests: fake provider only (no network), planted-secret redaction-boundary + per-reason fallback + call-cap + numbers-from-report coverage. Refs #957
446 lines
18 KiB
Python
446 lines
18 KiB
Python
"""Тесты эндпоинта чата `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_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"]
|
||
|
||
|
||
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"]
|