feat(chat): LLM tool-loop + §19 redaction wiring for #957 (Step 2+3)
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
This commit is contained in:
Light1YT 2026-06-08 17:45:01 +05:00
parent 4847a2eb01
commit fceaaf9a2c
9 changed files with 1244 additions and 20 deletions

View file

@ -1,13 +1,19 @@
"""Чат по §22-форсайту участка — `POST /api/v1/chat/ask` (#957, Step 1, БЕЗ LLM).
"""Чат по §22-форсайту участка — `POST /api/v1/chat/ask` (#957, Step 1 + Step 2 LLM).
ДЕТЕРМИНИРОВАННЫЙ stateless-эндпоинт: читает УЖЕ-ПЕРСИСТЕНТНЫЙ SiteFinderReport
участка и отдаёт шаблонный RU-ответ. LLM НЕ участвует (`llm_used=False` всегда)
композиция через LLM это Step 2.
Stateless-эндпоинт: читает УЖЕ-ПЕРСИСТЕНТНЫЙ SiteFinderReport участка и отдаёт RU-ответ.
Две ветки по `settings.llm_enabled`:
False (дефолт) ДЕТЕРМИНИРОВАННЫЙ Step-1 путь (route_intent render_answer),
llm_used=False. В проде до настройки секретов сеть НЕ дёргается.
True Step-2 LLM tool-loop (orchestrate_chat) поверх того же отчёта; при ЛЮБОМ
сбое LLM оркестратор сам деградирует в Step-1 ответ (llm_used=False + fallback_reason).
Поток:
get_report_for_chat отчёта нет (None) report_status='pending' + детерминированный
RU-ответ «запустите анализ участка» (READ-ONLY: НЕ enqueue'им расчёт); иначе
route_intent render_answer ChatAskResponse(report_status='ready', grounded_in).
RU-ответ «запустите анализ участка» (READ-ONLY: НЕ enqueue'им расчёт); иначе ветка по
llm_enabled ChatAskResponse(report_status='ready', grounded_in).
Syncasync мост: ядро LLM (`complete`) синхронное (httpx.Client) async-хендлер мостит
через `run_in_threadpool` (НЕ делаем async Celery/блокирующий вызов в event-loop'е).
RBAC: смонтировано под /api/v1/chat (НЕ /admin) middleware rbac_guard (app/main.py)
АВТОМАТИЧЕСКИ требует аутентифицированного известного юзера (X-Authenticated-User из
@ -18,14 +24,17 @@ get_parcel_forecast); хендлер async def (FastAPI house-style).
from __future__ import annotations
import logging
from typing import Annotated
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from fastapi.concurrency import run_in_threadpool
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.schemas.chat import ChatAskRequest, ChatAskResponse, ChatIntent, GroundedIn
from app.services.chat.intents import render_answer, route_intent
from app.services.chat.orchestrator import orchestrate_chat
from app.services.chat.retrieval import _FORECAST_SCHEMA_VERSION, get_report_for_chat
logger = logging.getLogger(__name__)
@ -45,12 +54,13 @@ async def ask(
payload: ChatAskRequest,
db: Annotated[Session, Depends(get_db)],
) -> ChatAskResponse:
"""Ответить на вопрос по §22-форсайту участка (детерминированно, БЕЗ LLM).
"""Ответить на вопрос по §22-форсайту участка.
Отчёта нет 200 + report_status='pending' + RU-подсказка «запустите анализ»
(READ-ONLY, ничего не считаем). Иначе роутим intent (явный или по тексту) и
собираем шаблонный RU-ответ с числами ВЕРБАТИМ из отчёта + advisory-оговорка.
`history` принимается, но в Step 1 НЕ используется (LLM-контекст Step 2).
(READ-ONLY, ничего не считаем). Иначе ветка по `settings.llm_enabled`:
False детерминированный Step-1 ответ (intent render_answer), llm_used=False;
True LLM tool-loop (orchestrate_chat через run_in_threadpool); при сбое LLM
оркестратор сам отдаёт детерминированный ответ (llm_used=False + fallback_reason).
"""
try:
report, run_id = get_report_for_chat(db, payload.cad_num)
@ -79,13 +89,22 @@ async def ask(
report_status="pending",
)
if settings.llm_enabled:
return await _answer_via_llm(db, payload, report, run_id)
return _answer_deterministic(payload, report, run_id)
def _answer_deterministic(
payload: ChatAskRequest,
report: dict[str, Any],
run_id: int,
) -> ChatAskResponse:
"""Step-1 детерминированный ответ: intent → шаблонный RU-текст (числа из отчёта)."""
intent = route_intent(payload.message, payload.intent)
answer, sections = render_answer(intent, report)
# fallback_reason: помечаем, когда intent не распознан (отдали меню тем) — для
# аналитики и будущего LLM-фолбэка (Step 2).
# аналитики (зеркалит поведение Step-1).
fallback_reason = "intent_unknown" if intent is ChatIntent.unknown else None
return ChatAskResponse(
answer=answer,
grounded_in=GroundedIn(
@ -97,3 +116,36 @@ async def ask(
fallback_reason=fallback_reason,
report_status="ready",
)
async def _answer_via_llm(
db: Session,
payload: ChatAskRequest,
report: dict[str, Any],
run_id: int,
) -> ChatAskResponse:
"""Step-2 LLM tool-loop. Синхронный `complete` мостится через run_in_threadpool.
Оркестратор инкапсулирует деградацию: ЛЮБОЙ сбой LLM детерминированный ответ
(llm_used=False + fallback_reason). Здесь просто переносим его поля в HTTP-контракт.
"""
result = await run_in_threadpool(
orchestrate_chat,
db,
payload.cad_num,
payload.message,
payload.history,
report,
run_id,
)
return ChatAskResponse(
answer=result.answer,
grounded_in=GroundedIn(
run_id=run_id,
schema_version=_FORECAST_SCHEMA_VERSION,
sections=result.sections,
),
llm_used=result.llm_used,
fallback_reason=result.fallback_reason,
report_status="ready",
)

View file

@ -1,15 +1,25 @@
"""Чат по §22-форсайту участка (#957). Step 1 — ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM.
"""Чат по §22-форсайту участка (#957). Step 1 детерминированный + Step 2 LLM tool-loop.
Два под-модуля:
retrieval read-only достаёт персистентный SiteFinderReport участка.
intents роутинг RU-вопроса в intent + сборка шаблонного RU-ответа из секций.
Под-модули:
retrieval read-only достаёт персистентный SiteFinderReport участка.
intents роутинг RU-вопроса в intent + шаблонный RU-ответ (Step-1, FALLBACK).
tools read-only секционные tool'ы (срезы загруженного report_dict).
safe_payload §19-гейт: единственная сборка исходящего SafePayload (аллоулист).
orchestrator LLM tool-loop + RU-проза поверх отчёта; при сбое LLM Step-1 fallback.
LLM-композиция (Step 2) ляжет поверх этого слоя; здесь LLM НЕ участвует.
LLM-слой ОПЦИОНАЛЬНЫЙ: при llm_enabled=False или любом сбое возвращается детерм. Step-1.
"""
from __future__ import annotations
from app.services.chat.intents import render_answer, route_intent
from app.services.chat.orchestrator import ChatResult, orchestrate_chat
from app.services.chat.retrieval import get_report_for_chat
__all__ = ["get_report_for_chat", "render_answer", "route_intent"]
__all__ = [
"ChatResult",
"get_report_for_chat",
"orchestrate_chat",
"render_answer",
"route_intent",
]

View file

@ -0,0 +1,202 @@
"""LLM tool-loop + RU-проза-композиция чата по §22-форсайту (#957, Step 2).
Поверх ДЕТЕРМИНИРОВАННОГО Step 1 (intents.render_answer) ОПЦИОНАЛЬНЫЙ LLM-слой.
Архитектурный закон проекта: форсайт-движок детерминирован, LLM ничего не «ломает»
при ЛЮБОМ сбое/выключенности возвращается детерминированный ответ Step 1.
ПОТОК (ручной tool-call loop ``complete`` это pass-through, НЕ агент):
1. ``complete(chat_system, payload(question, {}), tools=<5 секционных спек>, call_index=0)``.
2. Если модель попросила tool'ы → выполняем секционные срезы против УЖЕ-ЗАГРУЖЕННОГО
report_dict (tools.execute_tool НИКАКОЙ БД/recompute/engine-math), копим
section_data, пересобираем payload и зовём ``complete(..., call_index+1)``.
3. Повтор пока модель не вернёт финальную прозу (нет tool_calls) ЛИБО пока call_index
не упрётся в settings.llm_max_calls_per_request детерминированный fallback.
GROUNDING (§16): числа живут в section_data (= срезы отчёта); LLM лишь оборачивает их
в RU-прозу. system-prompt запрещает выдумывать числа (см. prompts.chat_system).
grounded_in.sections РЕАЛЬНО запрошенные моделью секции (честный provenance).
FALLBACK: любой ``LLMResult.ok is False`` (disabled/timeout/rate_limited/redaction_refused/
call_cap/no_api_key/provider_error) Step-1 ``render_answer`` с llm_used=False +
fallback_reason. Пустой финальный ответ модели тоже детерминированный fallback
(empty_response): пустую прозу клиенту не отдаём.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import settings
from app.schemas.chat import ChatTurn
from app.services.chat import intents, tools
from app.services.chat.safe_payload import build_chat_payload
from app.services.llm import LLMResult, complete, render
from app.services.llm.provider import LLMProvider, ToolCall
logger = logging.getLogger(__name__)
# Имя системного промпта чата (versioned-шаблон в llm/prompts.py).
_CHAT_SYSTEM_PROMPT = "chat_system"
@dataclass(frozen=True, slots=True)
class ChatResult:
"""Итог оркестрации одного хода чата (LLM-проза ИЛИ детерминированный fallback).
Attributes:
answer: финальный RU-текст (LLM-проза при llm_used; иначе шаблонный Step-1).
sections: секции отчёта, легшие в основу (provenance grounded_in.sections):
реально запрошенные tool'ами при llm_used; render_answer-секции при fallback.
llm_used: True только при успешной LLM-композиции.
fallback_reason: машиночитаемая причина деградации (None при llm_used).
"""
answer: str
sections: list[str] = field(default_factory=list)
llm_used: bool = False
fallback_reason: str | None = None
def _deterministic(report_dict: dict[str, Any], message: str, reason: str) -> ChatResult:
"""Детерминированный Step-1 ответ (FALLBACK). Числа ВЕРБАТИМ из отчёта, без LLM."""
intent = intents.route_intent(message, None)
answer, sections = intents.render_answer(intent, report_dict)
return ChatResult(
answer=answer,
sections=sections,
llm_used=False,
fallback_reason=reason,
)
def _run_tool_calls(
tool_calls: list[ToolCall],
report_dict: dict[str, Any],
section_data: dict[str, Any],
called_sections: list[str],
) -> None:
"""Выполнить запрошенные секционные tool'ы против report_dict, дополнив аккумуляторы.
PURE-срезы (tools.execute_tool): НИКАКОЙ БД/recompute/engine-math. Имя tool'а
под-dict в section_data (для следующего payload); реально затронутые секции отчёта
called_sections (provenance, без дублей). Неизвестное имя tool'а игнорируем безопасно
(execute_tool вернёт маркер «недоступно», sections_for_tool вернёт ()).
"""
for tc in tool_calls:
# Аргументы tool'ов наши спеки не определяют (секция фиксирована) — tc.arguments
# игнорируем намеренно: модель не управляет вычислениями, только выбирает секцию.
section_data[tc.name] = tools.execute_tool(tc.name, report_dict)
for sect in tools.sections_for_tool(tc.name):
if sect not in called_sections:
called_sections.append(sect)
def orchestrate_chat(
db: Session | None,
cad_num: str,
message: str,
history: list[ChatTurn] | None,
report_dict: dict[str, Any],
run_id: int,
*,
provider: LLMProvider | None = None,
) -> ChatResult:
"""Прогнать один ход чата через LLM tool-loop; при любом сбое — детерм. Step-1 fallback.
Args:
db: сессия БД (НЕ используется для пере-расчёта отчёт уже загружен; принимается
для единообразия сигнатуры и возможного будущего read-only обогащения. None
допустим оркестратор в БД не ходит).
cad_num: кадастровый номер (для логов/контекста).
message: вопрос пользователя (RU).
history: история диалога (Step 2: пока в payload НЕ форвардится узкий аллоулист
§19; расширение отдельным решением).
report_dict: УЖЕ-ЗАГРУЖЕННЫЙ ``SiteFinderReport.as_dict()`` (8 секций). Источник
ВСЕХ чисел срезы этого dict'а; движковая математика НЕ повторяется.
run_id: id рана-источника (для логов; grounded_in.run_id ставит эндпоинт).
provider: внедрить LLMProvider (тесты/RU-hosted). None собирается из settings
внутри ``complete`` (в проде).
Returns:
``ChatResult`` LLM-проза (llm_used=True) или детерминированный Step-1 fallback.
"""
del db, history # см. docstring: отчёт уже загружен; history пока не форвардим.
system_prompt = render(_CHAT_SYSTEM_PROMPT)
# Аккумуляторы между итерациями loop'а.
section_data: dict[str, Any] = {} # имя tool'а → срез секции (идёт в payload)
called_sections: list[str] = [] # реально затронутые секции отчёта (provenance)
specs = tools.tool_specs()
# call_index растёт с каждой итерацией; call-cap дублирует guard внутри complete,
# но держим явный предел и здесь, чтобы loop гарантированно завершался.
call_index = 0
max_calls = settings.llm_max_calls_per_request
while call_index < max_calls:
payload = build_chat_payload(message, section_data)
result: LLMResult = complete(
system_prompt=system_prompt,
payload=payload,
tools=specs,
provider=provider,
call_index=call_index,
)
if not result.ok:
# disabled / timeout / rate_limited / redaction_refused / call_cap /
# no_api_key / provider_error → детерминированный Step-1 ответ.
logger.info(
"chat: LLM unavailable (reason=%s) for cad=%s run=%s → deterministic fallback",
result.reason,
cad_num,
run_id,
)
return _deterministic(report_dict, message, result.reason or "llm_unavailable")
if result.tool_calls:
# Модель запросила секции — выполняем срезы и идём на следующий виток с
# накопленным контекстом (числа из отчёта, не из LLM).
_run_tool_calls(result.tool_calls, report_dict, section_data, called_sections)
call_index += 1
continue
# Нет tool_calls → финальная проза.
answer = (result.content or "").strip()
if not answer:
# Модель вернула пустоту — пустой ответ клиенту не отдаём, деградируем.
logger.info(
"chat: empty LLM content for cad=%s run=%s → deterministic fallback",
cad_num,
run_id,
)
return _deterministic(report_dict, message, "empty_response")
logger.info(
"chat: LLM-composed answer for cad=%s run=%s (sections=%s, calls=%d)",
cad_num,
run_id,
called_sections,
call_index,
)
return ChatResult(
answer=answer,
sections=called_sections,
llm_used=True,
fallback_reason=None,
)
# Loop исчерпал бюджет вызовов (модель всё просит tool'ы) → детерминированный fallback.
logger.info(
"chat: tool-loop hit call cap (%d) for cad=%s run=%s → deterministic fallback",
max_calls,
cad_num,
run_id,
)
return _deterministic(report_dict, message, "call_cap")
__all__ = ["ChatResult", "orchestrate_chat"]

View file

@ -0,0 +1,85 @@
"""§19 redaction boundary для чата (#957, Step 2/3) — ЕДИНСТВЕННАЯ точка сборки
исходящего ``SafePayload``.
КОНТРАКТ (читать ОБЯЗАТЕЛЬНО перед изменением) allowlist-first, §19 data-residency
Провайдер LLM ВНЕШНИЙ (OpenAI) всё, что попадёт в этот ``SafePayload``, ПОКИНЕТ РФ.
Поэтому сюда разрешено передавать ТОЛЬКО курируемые агрегаты секций отчёта
(``SiteFinderReport.as_dict()``), которые вызывающий уже отобрал секционными
tool'ами (см. tools.py). ЭТО ЕДИНСТВЕННОЕ место, через которое данные чата уходят
во внешнюю модель поэтому и аллоулист здесь.
РАЗРЕШЕНО в ``section_data`` / ``fields``:
срезы секций отчёта из tools.py (exec_summary / product_tz / future_market /
scoring / confidence / scenarios) это посчитанные advisory-агрегаты.
ЗАПРЕЩЕНО (НИКОГДА не должно сюда дойти):
сырой ``analyze``-blob / сырые строки БД (rosreestr_deals, parcels, );
свободный insight-текст / лиды / любой контент с PII;
любой источник, помеченный confidential.
Вторичная защита (belt-and-suspenders): ``complete`` всё равно прогонит ``text`` и
строковые значения ``fields`` через regex-scrub PII (redaction.py). Но это НЕ замена
аллоулиста основная гарантия в том, ЧТО сюда передаёт вызывающий.
``is_confidential``: прокидывается в ``SafePayload`` как есть. Для MVP отчёт это
не-чувствительные агрегаты False. Но параметр СУЩЕСТВУЕТ и ЧЕСТНО соблюдается: если
когда-нибудь источник окажется confidential, ``complete`` для внешнего провайдера
поднимет ``RedactionRefusedError`` чат деградирует в детерминированный fallback
(hard-block, данные наружу НЕ уходят).
"""
from __future__ import annotations
import logging
from typing import Any
from app.services.llm import SafePayload
logger = logging.getLogger(__name__)
def build_chat_payload(
question: str,
section_data: dict[str, Any],
*,
is_confidential: bool = False,
) -> SafePayload:
"""Собрать исходящий ``SafePayload`` чата из вопроса + КУРИРУЕМЫХ срезов секций.
ЕДИНСТВЕННАЯ точка сборки исходящей нагрузки чата (§19-гейт). Вызывающий ОБЯЗАН
передавать в ``section_data`` ТОЛЬКО агрегаты секций отчёта (срезы из tools.py)
НИКОГДА сырой ``analyze``-blob, сырые строки БД или insight-текст (см. модульный
docstring). Эта функция ничего не «достаёт» и не ходит в БД она лишь упаковывает
то, что ей дали, в ``SafePayload`` (который ``complete`` затем проскрабит/заблокирует).
Args:
question: вопрос пользователя (RU). Уйдёт в ``text`` и будет проскраблен от PII
внутри ``complete`` перед отправкой (вторичная защита).
section_data: накопленные срезы секций отчёта (имя tool'а → под-dict секции).
Аллоулист: только вывод секционных executors из tools.py.
is_confidential: флаг конфиденциальности источника. MVP: отчёт = агрегаты False.
True ``complete`` для внешнего провайдера поднимет RedactionRefusedError
(hard-block) вызывающий деградирует в детерминированный fallback.
Returns:
``SafePayload`` с ``text``=вопрос, ``fields``=section_data, флагом confidential.
"""
# НЕ логируем содержимое вопроса/секций (PII-гигиена) — только факт и размер.
logger.debug(
"chat: building safe payload (sections=%d, confidential=%s)",
len(section_data),
is_confidential,
)
return SafePayload(
text=question,
# dict(...) — копия: payload не должен держать ссылку на изменяемый аккумулятор
# вызывающего (loop дополняет section_data между итерациями).
fields=dict(section_data),
is_confidential=is_confidential,
)
__all__ = ["build_chat_payload"]

View file

@ -0,0 +1,170 @@
"""Read-only «секционные» tool'ы чата (#957, Step 2) — срезы УЖЕ-загруженного отчёта.
LLM в tool-loop'е (см. orchestrator.py) просит секции отчёта через function-calling.
Здесь две вещи и НИЧЕГО больше:
1. OpenAI tool-спеки (JSON-schema) пяти read-only секционных tool'ов
(`get_exec_summary` / `get_product_recommendation` / `get_forecast` /
`get_risks` / `get_scenarios`). Без параметров каждый отдаёт фиксированную
секцию(и) отчёта (модель не управляет вычислениями, только запрашивает данные).
2. ЧИСТЫЕ executors: режут УЖЕ-ЗАГРУЖЕННЫЙ in-memory `report_dict`
(`SiteFinderReport.as_dict()`, 8 секций). НИКАКОЙ БД, НИКАКОГО пере-расчёта,
НИКАКОЙ движковой математики только срез готового dict'а.
КРИТИЧНО (§16 grounding): числа берутся ВЕРБАТИМ из отчёта. Tool'ы не считают и не
выдумывают они достают под-dict секции как есть. Модель потом только оборачивает
эти числа в RU-прозу (compose), не изобретая своих.
ROBUST: отчёт может быть тонким/частичным (каждая секция Optional). Executors НИКОГДА
не бросают KeyError отсутствует секция `{}` или маленький маркер «недоступно».
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
# Маркер «секции нет в отчёте» (graceful, не KeyError). Модель по нему скажет, что
# данных нет, а не выдумает их (см. system-prompt в prompts.py).
_NOT_AVAILABLE: dict[str, Any] = {"available": False}
def _section(report: dict[str, Any], key: str) -> dict[str, Any]:
"""Достать секцию-dict по ключу или вернуть маркер «недоступно». PURE, без KeyError."""
value = report.get(key)
if isinstance(value, dict) and value:
return value
return dict(_NOT_AVAILABLE)
# ── Executors (PURE срезы готового report_dict; НЕТ БД/recompute/engine-math) ────
def get_exec_summary(report: dict[str, Any]) -> dict[str, Any]:
"""§13.1 exec_summary — вердикт-заголовок + ключевые числа + общая уверенность."""
return _section(report, "exec_summary")
def get_product_recommendation(report: dict[str, Any]) -> dict[str, Any]:
"""§13.4 product_tz — рекомендация продукта (класс / квартирография / USP / §16)."""
return _section(report, "product_tz")
def get_forecast(report: dict[str, Any]) -> dict[str, Any]:
"""§13.3 future_market — прогноз спроса/предложения по горизонтам + future-supply."""
return _section(report, "future_market")
def get_risks(report: dict[str, Any]) -> dict[str, Any]:
"""Риски — §13.6 scoring (спец-индексы §25) + §13.7 confidence (уровень/факторы).
Объединяет ДВЕ секции в один срез: риски в отчёте размазаны по scoring
(special_indices, вкл. каннибализацию) и confidence (уровень + тянущие факторы).
Каждая под-секция graceful: отсутствует маркер «недоступно», без KeyError.
"""
return {
"scoring": _section(report, "scoring"),
"confidence": _section(report, "confidence"),
}
def get_scenarios(report: dict[str, Any]) -> dict[str, Any]:
"""§13.5 scenarios — сводка conservative/base/aggressive + summary."""
return _section(report, "scenarios")
# ── Реестр имя→executor + имя→секции отчёта (для provenance grounded_in.sections) ─
# ЕДИНЫЙ источник истины: и спеки, и orchestrator берут имена/маппинг отсюда.
# Имя tool'а → (executor, какие секции отчёта он реально трогает). sections нужны для
# честного grounded_in.sections: что СПРАШИВАЛ LLM = на чём заземлён ответ.
_TOOLS: dict[str, tuple[Callable[[dict[str, Any]], dict[str, Any]], tuple[str, ...]]] = {
"get_exec_summary": (get_exec_summary, ("exec_summary",)),
"get_product_recommendation": (get_product_recommendation, ("product_tz",)),
"get_forecast": (get_forecast, ("future_market",)),
"get_risks": (get_risks, ("scoring", "confidence")),
"get_scenarios": (get_scenarios, ("scenarios",)),
}
# RU-описания tool'ов для модели (что внутри секции — чтобы LLM выбирал верный tool).
_TOOL_DESCRIPTIONS: dict[str, str] = {
"get_exec_summary": (
"Краткое резюме отчёта: вердикт «что строить», ключевые числа, общая уверенность."
),
"get_product_recommendation": (
"Рекомендация продукта: класс объекта, квартирография (mix), коммерция, "
"USP и §16-обоснования."
),
"get_forecast": (
"Прогноз будущего рынка: спрос/предложение по горизонтам, дефицит/затоварка, "
"давление будущего предложения."
),
"get_risks": (
"Риски участка: специальные индексы §25 (включая каннибализацию портфеля) "
"и уровень/факторы уверенности отчёта."
),
"get_scenarios": (
"Сценарии развития: разброс по консервативному / базовому / агрессивному."
),
}
def _spec(name: str) -> dict[str, Any]:
"""Собрать одну OpenAI function-tool спеку (без параметров — секция фиксирована)."""
return {
"type": "function",
"function": {
"name": name,
"description": _TOOL_DESCRIPTIONS[name],
# Без аргументов: tool отдаёт фиксированную секцию, модель не управляет
# вычислениями. additionalProperties=False — строгая пустая схема.
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
},
}
def tool_specs() -> list[dict[str, Any]]:
"""Все 5 секционных tool-спек (JSON-schema) для передачи в ``complete(tools=...)``."""
return [_spec(name) for name in _TOOLS]
def is_known_tool(name: str) -> bool:
"""Известен ли tool по имени (защита от галлюцинации имени моделью)."""
return name in _TOOLS
def execute_tool(name: str, report: dict[str, Any]) -> dict[str, Any]:
"""Выполнить секционный executor по имени против УЖЕ-загруженного report_dict.
PURE-срез: НИКАКОЙ БД/recompute/engine-math. Неизвестное имя маркер «недоступно»
(модель не уронит loop, если выдумает имя tool'а). KeyError невозможен (executors
graceful).
"""
entry = _TOOLS.get(name)
if entry is None:
return dict(_NOT_AVAILABLE)
executor, _ = entry
return executor(report)
def sections_for_tool(name: str) -> tuple[str, ...]:
"""Какие секции отчёта трогает tool (для grounded_in.sections). Неизвестный → ()."""
entry = _TOOLS.get(name)
return entry[1] if entry is not None else ()
__all__ = [
"execute_tool",
"get_exec_summary",
"get_forecast",
"get_product_recommendation",
"get_risks",
"get_scenarios",
"is_known_tool",
"sections_for_tool",
"tool_specs",
]

View file

@ -43,8 +43,34 @@ _SYSTEM_BASE = PromptTemplate(
),
)
# Системный шаблон чата по §22-форсайту (#957, Step 2). Версионируется (@vN) — изменение
# формулировки трассируемо. БЕЗ данных: данные приходят секционными tool'ами в SafePayload
# (см. services/chat/{tools,safe_payload}.py), НЕ зашиваются в литерал промпта.
_CHAT_SYSTEM = PromptTemplate(
name="chat_system",
version=1,
template=(
"Ты — ассистент по инвестиционному форсайт-отчёту земельного участка (РФ). "
"Отвечай на русском языке, по-деловому, нейтрально, без маркетинга и без emoji.\n\n"
"ЖЁСТКИЕ ПРАВИЛА:\n"
"1. Отвечай ТОЛЬКО на основе данных, полученных через инструменты (секции отчёта). "
"Чтобы получить нужные числа, вызови подходящий инструмент.\n"
"2. НИКОГДА не выдумывай числа, классы, доли или выводы. Если в полученной секции "
"данных нет (или помечено available=false) — честно скажи, что этих данных в "
"отчёте нет. Не подставляй правдоподобные значения.\n"
"3. Все числа в ответе бери ВЕРБАТИМ из секций инструментов, ничего не пересчитывай.\n"
"4. Тон советующий: отчёт помогает принять решение, но НЕ является основанием для "
"инвестиционного решения. Не давай гарантий доходности.\n"
"5. Вопросы вне отчёта по участку (градостроительная документация / ПЗЗ-разрешения, "
"сравнение с другими участками, юридические заключения) — вежливо скажи, что это вне "
"области отчёта, и предложи вопросы по самому форсайту.\n"
"Отвечай кратко и по существу заданного вопроса."
),
)
_TEMPLATES: dict[str, PromptTemplate] = {
_SYSTEM_BASE.name: _SYSTEM_BASE,
_CHAT_SYSTEM.name: _CHAT_SYSTEM,
}

View file

@ -27,8 +27,10 @@ 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).
@ -334,3 +336,111 @@ def test_ask_known_user_passes_rbac() -> None:
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"]

View file

@ -0,0 +1,418 @@
"""Тесты 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"

View file

@ -0,0 +1,151 @@
"""Тесты секционных tool'ов чата (#957, Step 2) — PURE срезы report_dict, без БД/recompute.
Покрываем:
tool_specs: 5 спек, валидная OpenAI-форма, без параметров.
executors: достают нужную секцию ВЕРБАТИМ; числа не пересчитываются.
robust: отсутствует секция маркер «недоступно», НЕ KeyError.
execute_tool/sections_for_tool: реестр имяexecutor + provenance-секции.
неизвестное имя tool'а: безопасный маркер + пустые секции (не падаем).
"""
from __future__ import annotations
from typing import Any
from app.services.chat import tools
_EXPECTED_TOOLS = {
"get_exec_summary",
"get_product_recommendation",
"get_forecast",
"get_risks",
"get_scenarios",
}
def _report() -> dict[str, Any]:
"""Узнаваемый отчёт (форма SiteFinderReport.as_dict())."""
return {
"exec_summary": {
"headline": "Комфорт-класс",
"key_numbers": {"цена_руб_м2": 250000},
"overall_confidence": "medium",
},
"future_market": {
"forecasts_by_horizon": [{"horizon": 12}, {"horizon": 24}],
"future_supply": {"pressure": 0.4},
},
"product_tz": {"obj_class": "комфорт", "mix": [{"fmt": "2k"}]},
"scoring": {"special_indices": {"indices": {"cannibalization": {"value": 0.31}}}},
"confidence": {"level": "medium", "factors": {"deals": "ok"}},
"scenarios": {"by_scenario": {"base": {}, "conservative": {}}},
}
# ── Спеки ───────────────────────────────────────────────────────────────────────
def test_tool_specs_cover_all_five() -> None:
specs = tools.tool_specs()
assert len(specs) == 5
assert {s["function"]["name"] for s in specs} == _EXPECTED_TOOLS
def test_tool_specs_are_valid_openai_function_shape() -> None:
for spec in tools.tool_specs():
assert spec["type"] == "function"
fn = spec["function"]
assert isinstance(fn["name"], str) and fn["name"]
assert isinstance(fn["description"], str) and fn["description"]
params = fn["parameters"]
assert params["type"] == "object"
# Секционные tool'ы без аргументов (модель не управляет вычислениями).
assert params["properties"] == {}
assert params["additionalProperties"] is False
# ── Executors: верные срезы, числа вербатим ──────────────────────────────────────
def test_exec_summary_slice_is_verbatim() -> None:
out = tools.get_exec_summary(_report())
assert out["headline"] == "Комфорт-класс"
# Число берётся как есть, не пересчитывается.
assert out["key_numbers"]["цена_руб_м2"] == 250000
def test_product_recommendation_slice() -> None:
out = tools.get_product_recommendation(_report())
assert out["obj_class"] == "комфорт"
assert out["mix"] == [{"fmt": "2k"}]
def test_forecast_slice() -> None:
out = tools.get_forecast(_report())
assert len(out["forecasts_by_horizon"]) == 2
assert out["future_supply"]["pressure"] == 0.4
def test_risks_merges_scoring_and_confidence() -> None:
out = tools.get_risks(_report())
assert set(out) == {"scoring", "confidence"}
indices = out["scoring"]["special_indices"]["indices"]
assert indices["cannibalization"]["value"] == 0.31
assert out["confidence"]["level"] == "medium"
def test_scenarios_slice() -> None:
out = tools.get_scenarios(_report())
assert set(out["by_scenario"]) == {"base", "conservative"}
# ── Robust: отсутствующая/пустая секция → маркер, НЕ KeyError ─────────────────────
def test_missing_section_returns_not_available_marker() -> None:
assert tools.get_exec_summary({}) == {"available": False}
assert tools.get_forecast({}) == {"available": False}
assert tools.get_scenarios({}) == {"available": False}
def test_risks_robust_to_missing_subsections() -> None:
out = tools.get_risks({})
assert out == {
"scoring": {"available": False},
"confidence": {"available": False},
}
def test_empty_section_dict_treated_as_unavailable() -> None:
# Пустой dict секции → маркер (нечего показывать), а не «{}» как валидные данные.
assert tools.get_product_recommendation({"product_tz": {}}) == {"available": False}
# ── Реестр: execute_tool / sections_for_tool / is_known_tool ─────────────────────
def test_execute_tool_dispatches_by_name() -> None:
out = tools.execute_tool("get_exec_summary", _report())
assert out["headline"] == "Комфорт-класс"
def test_execute_unknown_tool_returns_marker_not_raise() -> None:
assert tools.execute_tool("get_nonexistent", _report()) == {"available": False}
def test_sections_for_tool_provenance() -> None:
assert tools.sections_for_tool("get_exec_summary") == ("exec_summary",)
assert tools.sections_for_tool("get_risks") == ("scoring", "confidence")
assert tools.sections_for_tool("get_nonexistent") == ()
def test_is_known_tool() -> None:
assert tools.is_known_tool("get_forecast") is True
assert tools.is_known_tool("nope") is False
def test_executors_do_not_mutate_report() -> None:
report = _report()
before = dict(report["exec_summary"])
tools.get_exec_summary(report)
assert report["exec_summary"] == before