gendesign/backend/tests/services/chat/test_orchestrator.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

418 lines
17 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.

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