"""Чат по §22-форсайту участка — `POST /api/v1/chat/ask` (#957, Step 1, БЕЗ LLM). ДЕТЕРМИНИРОВАННЫЙ stateless-эндпоинт: читает УЖЕ-ПЕРСИСТЕНТНЫЙ SiteFinderReport участка и отдаёт шаблонный RU-ответ. LLM НЕ участвует (`llm_used=False` всегда) — композиция через LLM это Step 2. Поток: get_report_for_chat → отчёта нет (None) → report_status='pending' + детерминированный RU-ответ «запустите анализ участка» (READ-ONLY: НЕ enqueue'им расчёт); иначе route_intent → render_answer → ChatAskResponse(report_status='ready', grounded_in). 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 from fastapi import APIRouter, Depends from sqlalchemy.orm import Session 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.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-форсайту участка (детерминированно, БЕЗ LLM). Отчёта нет → 200 + report_status='pending' + RU-подсказка «запустите анализ» (READ-ONLY, ничего не считаем). Иначе роутим intent (явный или по тексту) и собираем шаблонный RU-ответ с числами ВЕРБАТИМ из отчёта + advisory-оговорка. `history` принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2). """ 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", ) intent = route_intent(payload.message, payload.intent) answer, sections = render_answer(intent, report) # fallback_reason: помечаем, когда intent не распознан (отдали меню тем) — для # аналитики и будущего LLM-фолбэка (Step 2). 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", )