diff --git a/backend/app/api/v1/chat.py b/backend/app/api/v1/chat.py index a69cffd4..a9b5fc90 100644 --- a/backend/app/api/v1/chat.py +++ b/backend/app/api/v1/chat.py @@ -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). + +Sync↔async мост: ядро 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", + ) diff --git a/backend/app/services/chat/__init__.py b/backend/app/services/chat/__init__.py index 8802281a..de5ed47d 100644 --- a/backend/app/services/chat/__init__.py +++ b/backend/app/services/chat/__init__.py @@ -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", +] diff --git a/backend/app/services/chat/orchestrator.py b/backend/app/services/chat/orchestrator.py new file mode 100644 index 00000000..f1f5b768 --- /dev/null +++ b/backend/app/services/chat/orchestrator.py @@ -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"] diff --git a/backend/app/services/chat/safe_payload.py b/backend/app/services/chat/safe_payload.py new file mode 100644 index 00000000..da058c49 --- /dev/null +++ b/backend/app/services/chat/safe_payload.py @@ -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"] diff --git a/backend/app/services/chat/tools.py b/backend/app/services/chat/tools.py new file mode 100644 index 00000000..723a2fb3 --- /dev/null +++ b/backend/app/services/chat/tools.py @@ -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", +] diff --git a/backend/app/services/llm/prompts.py b/backend/app/services/llm/prompts.py index eab74477..dfcf509f 100644 --- a/backend/app/services/llm/prompts.py +++ b/backend/app/services/llm/prompts.py @@ -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, } diff --git a/backend/tests/api/v1/test_chat.py b/backend/tests/api/v1/test_chat.py index 3f4aa13b..3808da05 100644 --- a/backend/tests/api/v1/test_chat.py +++ b/backend/tests/api/v1/test_chat.py @@ -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"] diff --git a/backend/tests/services/chat/test_orchestrator.py b/backend/tests/services/chat/test_orchestrator.py new file mode 100644 index 00000000..994102dd --- /dev/null +++ b/backend/tests/services/chat/test_orchestrator.py @@ -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" diff --git a/backend/tests/services/chat/test_tools.py b/backend/tests/services/chat/test_tools.py new file mode 100644 index 00000000..d67a626e --- /dev/null +++ b/backend/tests/services/chat/test_tools.py @@ -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