"""Тесты 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"