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
418 lines
17 KiB
Python
418 lines
17 KiB
Python
"""Тесты LLM tool-loop оркестратора чата (#957, Step 2 + §19 redaction-граница).
|
||
|
||
Сеть НЕ дёргается: всегда внедряем FAKE LLMProvider через complete(provider=...).
|
||
Покрываем:
|
||
• tool-call → execute slicer → compose: llm_used=True, grounded_in.sections верны.
|
||
• каждый LLMResult.ok=False reason (disabled/timeout/rate_limited/redaction_refused/
|
||
call_cap/no_api_key/provider_error) → детерминированный Step-1 fallback, llm_used=False.
|
||
• §19 redaction-граница: исходящий SafePayload.fields = ТОЛЬКО агрегаты секций,
|
||
НИКОГДА не сырой analyze-blob / insight-текст; is_confidential=True → fallback,
|
||
ZERO provider-вызовов.
|
||
• call-cap (модель всё просит tool'ы) → fallback; loop завершается.
|
||
• числа в section_data приходят ИЗ отчёта (fake echoes section_data; числа совпадают
|
||
с отчётом, чат-код ничего не выдумывает).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.core.config import settings
|
||
from app.services.chat.orchestrator import ChatResult, orchestrate_chat
|
||
from app.services.llm.provider import (
|
||
LLMProvider,
|
||
LLMProviderError,
|
||
LLMRateLimitedError,
|
||
LLMTimeoutError,
|
||
ProviderResponse,
|
||
ToolCall,
|
||
)
|
||
|
||
_CAD = "66:41:0204016:10"
|
||
_RUN_ID = 42
|
||
_NBSP = " " # неразрывный пробел — разделитель тысяч (зеркало intents._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",
|
||
},
|
||
"future_market": {
|
||
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
|
||
"future_supply": {"pressure": 0.4},
|
||
"summary": "Ожидается дефицит на горизонте 12 мес.",
|
||
},
|
||
"product_tz": {"obj_class": "комфорт", "mix": [{"fmt": "2k"}]},
|
||
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
|
||
"confidence": {"level": "medium", "rationale": "Прогноз спроса — прокси."},
|
||
"scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
|
||
}
|
||
|
||
|
||
# ── Fake-провайдеры (внешние; внедряются через provider=...) ──────────────────────
|
||
|
||
|
||
class _ScriptedProvider(LLMProvider):
|
||
"""Внешний fake: отдаёт заранее заданную последовательность ProviderResponse.
|
||
|
||
Каждый вызов complete() возвращает следующий элемент скрипта и фиксирует
|
||
messages/tools (для проверки исходящей нагрузки = §19-граница).
|
||
"""
|
||
|
||
def __init__(self, script: list[ProviderResponse]) -> None:
|
||
self._script = script
|
||
self.calls = 0
|
||
self.captured_messages: list[list[dict[str, Any]]] = []
|
||
self.captured_tools: list[list[dict[str, Any]] | None] = []
|
||
|
||
@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.captured_messages.append(messages)
|
||
self.captured_tools.append(tools)
|
||
resp = self._script[min(self.calls, len(self._script) - 1)]
|
||
self.calls += 1
|
||
return resp
|
||
|
||
|
||
class _EchoProvider(LLMProvider):
|
||
"""Внешний fake: на 1-м вызове просит указанные tool'ы, на 2-м ЭХОм возвращает
|
||
полученный user-контент (section_data) как прозу. Доказывает, что числа в ответе
|
||
приходят ИЗ отчёта (через section_data), а не выдумываются чат-кодом.
|
||
"""
|
||
|
||
def __init__(self, tool_names: list[str]) -> None:
|
||
self._tool_names = tool_names
|
||
self.calls = 0
|
||
self.captured_messages: list[list[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.captured_messages.append(messages)
|
||
self.calls += 1
|
||
if self.calls == 1:
|
||
calls = [
|
||
ToolCall(id=f"c{i}", name=name, arguments="{}")
|
||
for i, name in enumerate(self._tool_names)
|
||
]
|
||
return ProviderResponse(content=None, tool_calls=calls, model="gpt-4o-mini")
|
||
# 2-й вызов: эхо user-сообщения (последнее) — там лежит section_data.
|
||
user_msg = messages[-1]["content"]
|
||
return ProviderResponse(content=f"ИТОГ: {user_msg}", model="gpt-4o-mini")
|
||
|
||
|
||
class _RaisingProvider(LLMProvider):
|
||
"""Внешний fake, всегда бросающий заданное исключение (guardrail-reasons)."""
|
||
|
||
def __init__(self, exc: Exception) -> None:
|
||
self._exc = exc
|
||
self.calls = 0
|
||
|
||
@property
|
||
def is_external(self) -> bool:
|
||
return True
|
||
|
||
@property
|
||
def model(self) -> str:
|
||
return "gpt-4o-mini"
|
||
|
||
def complete(self, messages: Any, *, tools: Any = None, max_output_tokens: int) -> Any:
|
||
self.calls += 1
|
||
raise self._exc
|
||
|
||
|
||
@pytest.fixture
|
||
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Включить LLM с fake-ключом (без реальных секретов)."""
|
||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||
monkeypatch.setattr(settings, "openai_api_key", "test-fake-key-not-real")
|
||
# Не спим на ретраях (rate-limit reason).
|
||
monkeypatch.setattr("app.services.llm.client.time.sleep", lambda s: None)
|
||
|
||
|
||
def _run(provider: LLMProvider, message: str = "Что здесь строить?") -> ChatResult:
|
||
return orchestrate_chat(
|
||
db=None, # оркестратор отчёт уже получил; БД не трогает (del db)
|
||
cad_num=_CAD,
|
||
message=message,
|
||
history=None,
|
||
report_dict=_report(),
|
||
run_id=_RUN_ID,
|
||
provider=provider,
|
||
)
|
||
|
||
|
||
# ── Happy path: tool-call → slice → compose ──────────────────────────────────────
|
||
|
||
|
||
def test_tool_call_then_compose_sets_llm_used_and_sections(_enabled: None) -> None:
|
||
"""Модель просит exec_summary → slice → проза. llm_used=True, sections верны."""
|
||
prov = _ScriptedProvider(
|
||
[
|
||
ProviderResponse(
|
||
content=None,
|
||
tool_calls=[ToolCall(id="c1", name="get_exec_summary", arguments="{}")],
|
||
model="gpt-4o-mini",
|
||
),
|
||
ProviderResponse(content="Рекомендуется комфорт-класс.", model="gpt-4o-mini"),
|
||
]
|
||
)
|
||
res = _run(prov)
|
||
assert res.llm_used is True
|
||
assert res.fallback_reason is None
|
||
assert res.answer == "Рекомендуется комфорт-класс."
|
||
assert res.sections == ["exec_summary"]
|
||
assert prov.calls == 2
|
||
|
||
|
||
def test_multiple_tools_provenance_dedup(_enabled: None) -> None:
|
||
"""Несколько tool'ов (вкл. get_risks=2 секции) → provenance объединяет без дублей."""
|
||
prov = _ScriptedProvider(
|
||
[
|
||
ProviderResponse(
|
||
content=None,
|
||
tool_calls=[
|
||
ToolCall(id="c1", name="get_exec_summary", arguments="{}"),
|
||
ToolCall(id="c2", name="get_risks", arguments="{}"),
|
||
],
|
||
model="gpt-4o-mini",
|
||
),
|
||
ProviderResponse(content="Готовый ответ.", model="gpt-4o-mini"),
|
||
]
|
||
)
|
||
res = _run(prov)
|
||
assert res.llm_used is True
|
||
# get_exec_summary→exec_summary; get_risks→scoring,confidence. Порядок сохранён.
|
||
assert res.sections == ["exec_summary", "scoring", "confidence"]
|
||
|
||
|
||
def test_first_call_no_tools_returns_prose_immediately(_enabled: None) -> None:
|
||
"""Модель сразу вернула прозу (без tool'ов) → llm_used=True, sections пусты."""
|
||
prov = _ScriptedProvider([ProviderResponse(content="Краткий ответ.", model="gpt-4o-mini")])
|
||
res = _run(prov)
|
||
assert res.llm_used is True
|
||
assert res.answer == "Краткий ответ."
|
||
assert res.sections == []
|
||
assert prov.calls == 1
|
||
|
||
|
||
def test_numbers_come_from_report_not_invented(_enabled: None) -> None:
|
||
"""Fake ЭХОм возвращает section_data → числа в ответе РОВНО из отчёта (вербатим)."""
|
||
prov = _EchoProvider(["get_exec_summary", "get_forecast"])
|
||
res = _run(prov)
|
||
assert res.llm_used is True
|
||
# 250000 и 0.4 — числа отчёта; они в ответе, потому что пришли через section_data.
|
||
assert "250000" in res.answer
|
||
assert "0.4" in res.answer
|
||
# Секции запрошены реально.
|
||
assert res.sections == ["exec_summary", "future_market"]
|
||
|
||
|
||
# ── §19 redaction-граница: что может/НЕ может попасть в исходящий payload ──────────
|
||
|
||
|
||
def test_outbound_payload_contains_only_section_aggregates(_enabled: None) -> None:
|
||
"""ГЛАВНОЕ §19: после tool-call в исходящих fields ТОЛЬКО срезы секций — ни сырого
|
||
analyze-blob, ни insight-текста. Числа секций — да; чужих ключей — нет.
|
||
"""
|
||
prov = _ScriptedProvider(
|
||
[
|
||
ProviderResponse(
|
||
content=None,
|
||
tool_calls=[ToolCall(id="c1", name="get_exec_summary", arguments="{}")],
|
||
model="gpt-4o-mini",
|
||
),
|
||
ProviderResponse(content="ок", model="gpt-4o-mini"),
|
||
]
|
||
)
|
||
_run(prov)
|
||
# 2-й исходящий запрос несёт section_data в user-сообщении.
|
||
second_user = prov.captured_messages[1][-1]["content"]
|
||
# Аллоулист: ключ tool'а + содержимое секции присутствуют.
|
||
assert "get_exec_summary" in second_user
|
||
assert "Комфорт-класс" in second_user # агрегат секции
|
||
# А вот «сырых» источников там быть НЕ должно (их и нет в section_data).
|
||
assert "analyze" not in second_user
|
||
assert "rosreestr" not in second_user
|
||
assert "insight" not in second_user
|
||
|
||
|
||
def test_raw_report_keys_outside_tools_never_leak(_enabled: None) -> None:
|
||
"""Секции отчёта, которые модель НЕ запрашивала (market_now), НЕ уходят наружу."""
|
||
report = _report()
|
||
# Подложим «сырой» секрет в секцию, которую tool'ы НЕ режут (нет get_market_now).
|
||
report["market_now"] = {"secret_raw_deals": "+7 912 345 67 89 Иванов Иван Иванович"}
|
||
|
||
prov = _ScriptedProvider(
|
||
[
|
||
ProviderResponse(
|
||
content=None,
|
||
tool_calls=[ToolCall(id="c1", name="get_forecast", arguments="{}")],
|
||
model="gpt-4o-mini",
|
||
),
|
||
ProviderResponse(content="ок", model="gpt-4o-mini"),
|
||
]
|
||
)
|
||
orchestrate_chat(
|
||
db=None,
|
||
cad_num=_CAD,
|
||
message="прогноз",
|
||
history=None,
|
||
report_dict=report,
|
||
run_id=_RUN_ID,
|
||
provider=prov,
|
||
)
|
||
blob = json.dumps(prov.captured_messages, ensure_ascii=False)
|
||
# market_now не запрашивался → его сырьё нигде в исходящих messages.
|
||
assert "secret_raw_deals" not in blob
|
||
assert "market_now" not in blob
|
||
|
||
|
||
def test_confidential_payload_blocks_with_zero_provider_calls(
|
||
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""is_confidential=True → RedactionRefusedError в complete → fallback, 0 вызовов
|
||
провайдера. Эмулируем confidential, заставив build_chat_payload помечать нагрузку.
|
||
"""
|
||
import app.services.chat.orchestrator as orch
|
||
from app.services.llm import SafePayload
|
||
|
||
def _confidential_payload(question: str, section_data: dict[str, Any], **_: Any) -> SafePayload:
|
||
return SafePayload(text=question, fields=dict(section_data), is_confidential=True)
|
||
|
||
monkeypatch.setattr(orch, "build_chat_payload", _confidential_payload)
|
||
prov = _ScriptedProvider([ProviderResponse(content="не должно дойти", model="gpt-4o-mini")])
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "redaction_refused"
|
||
# Hard-block ДО провайдера: ни одного вызова.
|
||
assert prov.calls == 0
|
||
# Ответ — детерминированный Step-1 (содержит advisory-оговорку).
|
||
assert "советующий характер" in res.answer
|
||
|
||
|
||
# ── LLMResult.ok=False reasons → детерминированный Step-1 fallback ────────────────
|
||
|
||
|
||
def test_disabled_falls_back_without_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""llm_enabled=False → reason='disabled', провайдер не вызывается."""
|
||
monkeypatch.setattr(settings, "llm_enabled", False)
|
||
prov = _ScriptedProvider([ProviderResponse(content="x", model="gpt-4o-mini")])
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "disabled"
|
||
assert prov.calls == 0
|
||
assert "советующий характер" in res.answer
|
||
|
||
|
||
def test_timeout_falls_back(_enabled: None) -> None:
|
||
prov = _RaisingProvider(LLMTimeoutError("slow"))
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "timeout"
|
||
|
||
|
||
def test_rate_limited_falls_back(_enabled: None) -> None:
|
||
prov = _RaisingProvider(LLMRateLimitedError("429", status_code=429, retry_after=None))
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "rate_limited"
|
||
|
||
|
||
def test_provider_error_falls_back(_enabled: None) -> None:
|
||
prov = _RaisingProvider(LLMProviderError("boom"))
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "provider_error"
|
||
|
||
|
||
def test_no_api_key_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""llm_enabled=True но ключ None и provider=None → reason='no_api_key' fallback."""
|
||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||
monkeypatch.setattr(settings, "openai_api_key", None)
|
||
res = orchestrate_chat(
|
||
db=None,
|
||
cad_num=_CAD,
|
||
message="резюме",
|
||
history=None,
|
||
report_dict=_report(),
|
||
run_id=_RUN_ID,
|
||
provider=None,
|
||
)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "no_api_key"
|
||
|
||
|
||
def test_fallback_answer_is_grounded_step1(_enabled: None) -> None:
|
||
"""Fallback-ответ — реальный Step-1 рендер: для summary несёт число отчёта."""
|
||
prov = _RaisingProvider(LLMTimeoutError("slow"))
|
||
res = _run(prov, message="дай резюме по участку")
|
||
assert res.llm_used is False
|
||
# Step-1 summary тянет exec_summary с числом 250000 (формат NBSP-тысяч).
|
||
assert res.sections == ["exec_summary"]
|
||
assert f"250{_NBSP}000" in res.answer
|
||
|
||
|
||
# ── Call-cap → fallback; loop завершается ────────────────────────────────────────
|
||
|
||
|
||
def test_call_cap_terminates_loop_and_falls_back(
|
||
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Модель ВСЕГДА просит tool'ы → loop упирается в call-cap → детерм. fallback."""
|
||
monkeypatch.setattr(settings, "llm_max_calls_per_request", 3)
|
||
# Каждый ответ — снова tool_call (никогда не финальная проза).
|
||
always_tool = ProviderResponse(
|
||
content=None,
|
||
tool_calls=[ToolCall(id="c", name="get_exec_summary", arguments="{}")],
|
||
model="gpt-4o-mini",
|
||
)
|
||
prov = _ScriptedProvider([always_tool])
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "call_cap"
|
||
# Loop завершился: не более max_calls обращений к провайдеру.
|
||
assert prov.calls <= 3
|
||
|
||
|
||
def test_empty_final_content_falls_back(_enabled: None) -> None:
|
||
"""Модель вернула пустую прозу → не отдаём пустоту, детерм. fallback."""
|
||
prov = _ScriptedProvider([ProviderResponse(content=" ", model="gpt-4o-mini")])
|
||
res = _run(prov)
|
||
assert res.llm_used is False
|
||
assert res.fallback_reason == "empty_response"
|