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
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:
parent
4847a2eb01
commit
fceaaf9a2c
9 changed files with 1244 additions and 20 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
202
backend/app/services/chat/orchestrator.py
Normal file
202
backend/app/services/chat/orchestrator.py
Normal 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"]
|
||||
85
backend/app/services/chat/safe_payload.py
Normal file
85
backend/app/services/chat/safe_payload.py
Normal 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"]
|
||||
170
backend/app/services/chat/tools.py
Normal file
170
backend/app/services/chat/tools.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
418
backend/tests/services/chat/test_orchestrator.py
Normal file
418
backend/tests/services/chat/test_orchestrator.py
Normal 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"
|
||||
151
backend/tests/services/chat/test_tools.py
Normal file
151
backend/tests/services/chat/test_tools.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue