"""Чат по §22-форсайту участка — `POST /api/v1/chat/ask` (#957, Step 1 + Step 2 LLM). 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'им расчёт); иначе ветка по 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 из Caddy). Доп. guard-код тут НЕ нужен. Сессия БД — синхронная (`Depends(get_db)`, как get_parcel_forecast); хендлер async def (FastAPI house-style). """ from __future__ import annotations import logging 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__) router = APIRouter() # Детерминированный RU-ответ, когда §22-отчёта ещё нет (READ-ONLY: чат не триггерит # расчёт — его запускает POST /analyze). _PENDING_ANSWER = ( "Отчёт по этому участку ещё не готов. Запустите анализ участка — после расчёта " "форсайта я смогу ответить на вопросы по нему." ) @router.post("/ask", response_model=ChatAskResponse) async def ask( payload: ChatAskRequest, db: Annotated[Session, Depends(get_db)], ) -> ChatAskResponse: """Ответить на вопрос по §22-форсайту участка. Отчёта нет → 200 + report_status='pending' + RU-подсказка «запустите анализ» (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) except Exception: # Read-only сбой БД — не валим клиента 500-кой, отдаём pending (как # get_parcel_forecast). Клиент может повторить. logger.warning( "chat: report read failed for cad=%s — returning pending", payload.cad_num, exc_info=True, ) return ChatAskResponse( answer=_PENDING_ANSWER, grounded_in=None, llm_used=False, fallback_reason="report_unavailable", report_status="pending", ) if report is None or run_id is None: return ChatAskResponse( answer=_PENDING_ANSWER, grounded_in=None, llm_used=False, fallback_reason="report_pending", 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 не распознан (отдали меню тем) — для # аналитики (зеркалит поведение Step-1). fallback_reason = "intent_unknown" if intent is ChatIntent.unknown else None return ChatAskResponse( answer=answer, grounded_in=GroundedIn( run_id=run_id, schema_version=_FORECAST_SCHEMA_VERSION, sections=sections, ), llm_used=False, 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", )