gendesign/backend/tests/api/v1/test_chat.py
Light1YT fceaaf9a2c
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
feat(chat): LLM tool-loop + §19 redaction wiring for #957 (Step 2+3)
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
2026-06-08 17:45:01 +05:00

446 lines
18 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.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"]